initial commit
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 19 Sep 2026 18:50:12 +0000 (21:50 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 19 Sep 2026 18:50:12 +0000 (21:50 +0300)
248 files changed:
.gitignore [new file with mode: 0644]
AGENTS.org [new file with mode: 0644]
COPYING [new file with mode: 0644]
TODO.org [new file with mode: 0644]
Tools/Open with IntelliJ IDEA [new file with mode: 0755]
Tools/Update web site [new file with mode: 0755]
doc/Agentic development/Golden workflow.svg [new file with mode: 0644]
doc/Agentic development/Headless lanes.svg [new file with mode: 0644]
doc/Agentic development/Pixel assertion.svg [new file with mode: 0644]
doc/Agentic development/diff-example.png [new file with mode: 0644]
doc/Agentic development/snapshot-example.png [new file with mode: 0644]
doc/CSG/BSP tree.svg [new file with mode: 0644]
doc/CSG/CSG demo.png [new file with mode: 0644]
doc/CSG/CSG intersect.svg [new file with mode: 0644]
doc/CSG/CSG operations.svg [new file with mode: 0644]
doc/CSG/CSG union.svg [new file with mode: 0644]
doc/CSG/Polygon clipping.svg [new file with mode: 0644]
doc/CSG/index.org [new file with mode: 0644]
doc/Coordinate system.svg [new file with mode: 0644]
doc/Depth buffer/index.org [new file with mode: 0644]
doc/Developer tools/Developer tools.png [new file with mode: 0644]
doc/Developer tools/Render alternative segments.png [new file with mode: 0644]
doc/Developer tools/Render polygon borders.png [new file with mode: 0644]
doc/Developer tools/Show segment boundaries.png [new file with mode: 0644]
doc/Developer tools/Thread timeline.png [new file with mode: 0644]
doc/Edge.svg [new file with mode: 0644]
doc/Example.png [new file with mode: 0644]
doc/Face triangle.svg [new file with mode: 0644]
doc/Frustum culling/Frustum diagram.svg [new file with mode: 0644]
doc/Frustum culling/P-vertex AABB.svg [new file with mode: 0644]
doc/Frustum culling/index.org [new file with mode: 0644]
doc/Global illumination/Bounce estimator.svg [new file with mode: 0644]
doc/Global illumination/GI pipeline.svg [new file with mode: 0644]
doc/Global illumination/Global illumination.png [new file with mode: 0644]
doc/Global illumination/Lightmap mapping.svg [new file with mode: 0644]
doc/Global illumination/gi-converged.png [new file with mode: 0644]
doc/Global illumination/gi-flat.png [new file with mode: 0644]
doc/Global illumination/gi-start.png [new file with mode: 0644]
doc/Global illumination/index.org [new file with mode: 0644]
doc/Mesh.svg [new file with mode: 0644]
doc/Near plane clip/Clip algorithm.svg [new file with mode: 0644]
doc/Near plane clip/Fan triangulation.svg [new file with mode: 0644]
doc/Near plane clip/Near plane straddle.svg [new file with mode: 0644]
doc/Near plane clip/index.org [new file with mode: 0644]
doc/Near plane clip/near-clip-after.png [new file with mode: 0644]
doc/Near plane clip/near-clip-before.png [new file with mode: 0644]
doc/Normal vector.svg [new file with mode: 0644]
doc/Perspective correct textures/Adaptive interval.svg [new file with mode: 0644]
doc/Perspective correct textures/Affine distortion.png [new file with mode: 0644]
doc/Perspective correct textures/Scanline correction.svg [new file with mode: 0644]
doc/Perspective correct textures/index.org [new file with mode: 0644]
doc/Point3D vertex.svg [new file with mode: 0644]
doc/Rendering loop/CPU scheduling.png [new file with mode: 0644]
doc/Rendering loop/Double buffering.svg [new file with mode: 0644]
doc/Rendering loop/Paint tiles.svg [new file with mode: 0644]
doc/Rendering loop/Painter's algorithm.svg [new file with mode: 0644]
doc/Rendering loop/Render pipeline.svg [new file with mode: 0644]
doc/Rendering loop/index.org [new file with mode: 0644]
doc/SDF textures/SDF concept.svg [new file with mode: 0644]
doc/SDF textures/SDF glyph pipeline.svg [new file with mode: 0644]
doc/SDF textures/SDF minification.svg [new file with mode: 0644]
doc/SDF textures/glyph-sdf-S.png [new file with mode: 0644]
doc/SDF textures/index.org [new file with mode: 0644]
doc/SDF textures/sdf-angled.png [new file with mode: 0644]
doc/SDF textures/sdf-far-zoom.png [new file with mode: 0644]
doc/SDF textures/sdf-far.png [new file with mode: 0644]
doc/SDF textures/sdf-mid.png [new file with mode: 0644]
doc/SDF textures/sdf-near.png [new file with mode: 0644]
doc/Shading/Ambient light comparison.svg [new file with mode: 0644]
doc/Shading/Distance attenuation.svg [new file with mode: 0644]
doc/Shading/Lambert cosine law.svg [new file with mode: 0644]
doc/Shading/Shaded sphere.png [new file with mode: 0644]
doc/Shading/Shading pipeline.svg [new file with mode: 0644]
doc/Shading/index.org [new file with mode: 0644]
doc/Stereoscopic rendering/Stereo geometry.svg [new file with mode: 0644]
doc/Stereoscopic rendering/Stereo per eye.svg [new file with mode: 0644]
doc/Stereoscopic rendering/Stereo pipeline.svg [new file with mode: 0644]
doc/Stereoscopic rendering/index.org [new file with mode: 0644]
doc/Stereoscopic rendering/mono-comparison.png [new file with mode: 0644]
doc/Stereoscopic rendering/stereo-side-by-side.png [new file with mode: 0644]
doc/Winding order.svg [new file with mode: 0644]
doc/export-docs.sh [new file with mode: 0755]
doc/index.org [new file with mode: 0644]
doc/style.css [new file with mode: 0644]
pom.xml [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java [new file with mode: 0755]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java [new file with mode: 0644]
src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java [new file with mode: 0644]
src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..31378ad
--- /dev/null
@@ -0,0 +1,9 @@
+/.idea/
+/target/
+/.classpath
+/.project
+/.settings/
+/doc/graphs/
+/doc/apidocs/
+/*.iml
+*.html
diff --git a/AGENTS.org b/AGENTS.org
new file mode 100644 (file)
index 0000000..7dc28c0
--- /dev/null
@@ -0,0 +1,575 @@
+:PROPERTIES:
+:ID:       d69fec29-3842-4e10-aece-89829c522c79
+:END:
+#+TITLE: Aukio 3D Engine - Quick Reference
+#+LANGUAGE: en
+#+OPTIONS: H:20 num:20 author:nil
+
+Software-based 3D rendering engine (no OpenGL/DirectX). Pure Java
+rasterizer with texture support, lighting, CSG operations, and camera
+navigation.
+
+* Quick Lookup: "I Want To..."
+:PROPERTIES:
+:ID:       5136cb8e-4ade-4a28-91f7-3ec0ee98721c
+:END:
+
+| Task                          | Class (path)                                        | Key Constructor/Method                                    |
+|-------------------------------+-----------------------------------------------------+-----------------------------------------------------------|
+| *Create a window*             | ~ViewFrame~ (~gui/ViewFrame.java~)                  | ~new ViewFrame()~ → ~.getViewPanel()~                     |
+| *Add shapes to scene*         | ~ShapeCollection~ (~raster/ShapeCollection.java~)   | ~viewPanel.getRootShapeCollection().addShape(shape)~      |
+| *Position camera*             | ~Camera~ (~gui/Camera.java~)                        | ~camera.getTransform().setTranslation(Point3D)~           |
+| *Create a wireframe cube*     | ~WireframeCube~ (~shapes/composite/wireframe/~)     | ~new WireframeCube(center, halfSize, appearance)~         |
+| *Create a solid cube*         | ~SolidPolygonCube~ (~shapes/composite/solid/~)      | ~new SolidPolygonCube(center, halfSize, color)~           |
+| *Create a line*               | ~Line~ (~shapes/basic/line/~)                       | ~new Line(p1, p2, color, width)~                          |
+| *Create a polygon*            | ~SolidPolygon~ (~shapes/basic/solidpolygon/~)       | ~SolidPolygon.triangle(...)~ or ~.quad(...)~              |
+| *Create a sphere (wireframe)* | ~WireframeSphere~ (~shapes/composite/wireframe/~)   | ~new WireframeSphere(center, radius, appearance)~         |
+| *Create a sphere (solid)*     | ~SolidPolygonSphere~ (~shapes/composite/solid/~)    | ~new SolidPolygonSphere(center, radius, segments, color)~ |
+| *Create text in 3D*           | ~TextCanvas~ (~shapes/composite/textcanvas/~)       | ~new TextCanvas(transform, text, fgColor, bgColor)~       |
+| *Add a light source*          | ~LightSource~ (~raster/lighting/~)                  | ~lighting.addLight(new LightSource(pos, color))~          |
+| *Enable shading*              | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.setShadingEnabled(true)~                           |
+| *CSG: subtract*               | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.subtract(otherShape)~                              |
+| *CSG: union*                  | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.union(otherShape)~                                 |
+| *CSG: intersect*              | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~shape.intersect(otherShape)~                             |
+| *Animate per-frame*           | ~FrameListener~ (~gui/FrameListener.java~)          | ~viewPanel.addFrameListener((panel, deltaMs) -> {...})~   |
+| *Handle mouse clicks*         | ~MouseInteractionController~ (~gui/humaninput/~)    | ~shape.setMouseInteractionController(controller)~         |
+| *Position/rotate shape*       | ~AbstractCompositeShape~ (~shapes/composite/base/~) | ~new AbstractCompositeShape(location)~                    |
+| *Hide/show shape groups*      | ~ShapeCollection~ (~raster/ShapeCollection.java~)   | ~.hideGroup("debug")~ / ~.showGroup("debug")~             |
+| *Create a billboard*          | ~Billboard~ (~shapes/basic/~)                       | ~new Billboard(position, scale, texture)~                 |
+| *Create a glowing point*      | ~GlowingPoint~ (~shapes/basic/~)                    | ~new GlowingPoint(position, scale, color)~                |
+| *Render without a window*     | ~Snapshot~ (~headless/Snapshot.java~)               | ~Snapshot.render(scene, lighting, pose, w, h)~            |
+| *Assert pixels painted*       | ~PixelAssertions~ (~headless/PixelAssertions.java~) | ~.unpaintedFraction(image, bg, x0, y0, x1, y1)~           |
+| *Compare against golden PNG*  | ~GoldenImage~ (~headless/GoldenImage.java~)         | ~.compare(actual, goldenFile, tolerance, maxFraction)~    |
+| *Dump scene state*            | ~SceneDump~ (~headless/SceneDump.java~)             | ~SceneDump.dump(scene, lighting, camera, gi)~             |
+
+* Code Examples
+:PROPERTIES:
+:ID:       fdd7c315-c513-4925-8ca5-acb1f87a7380
+:END:
+
+** Basic Scene Setup
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.gui.ViewFrame;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+// Create window with 3D view
+ViewFrame frame = new ViewFrame();
+ViewPanel viewPanel = frame.getViewPanel();
+ShapeCollection scene = viewPanel.getRootShapeCollection();
+
+// Position camera (behind origin, looking forward)
+viewPanel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -200));
+
+// Add shapes here...
+// scene.addShape(...);
+#+end_src
+
+** Creating Shapes
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.*;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.*;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.*;
+
+// Wireframe shapes (use LineAppearance for color/width)
+LineAppearance appearance = new LineAppearance(2.0, Color.CYAN);
+scene.addShape(new WireframeCube(new Point3D(0, 0, 200), 50, appearance));
+scene.addShape(new WireframeSphere(new Point3D(100, 0, 300), 40, appearance));
+scene.addShape(new WireframeBox(p1, p2, appearance));
+
+// Solid shapes (use Color directly)
+scene.addShape(new SolidPolygonCube(new Point3D(0, 0, 200), 50, Color.GREEN));
+scene.addShape(new SolidPolygonSphere(new Point3D(100, 0, 300), 40, 16, Color.RED));
+
+// Simple line
+scene.addShape(new Line(
+    new Point3D(-50, 0, 100),
+    new Point3D(50, 0, 100),
+    Color.YELLOW, 3.0
+));
+
+// Polygon (triangle or quad)
+scene.addShape(SolidPolygon.triangle(
+    new Point3D(0, 0, 0),
+    new Point3D(50, 0, 0),
+    new Point3D(25, 50, 0),
+    Color.BLUE
+));
+scene.addShape(SolidPolygon.quad(
+    new Point3D(-50, -50, 0),
+    new Point3D(50, -50, 0),
+    new Point3D(50, 50, 0),
+    new Point3D(-50, 50, 0),
+    Color.WHITE
+));
+#+end_src
+
+** Custom Composite Shape
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+AbstractCompositeShape myShape = new AbstractCompositeShape(new Point3D(0, 0, 200));
+myShape.addShape(new Line(p1, p2, Color.RED, 2.0));
+myShape.addShape(new SolidPolygonCube(Point3D.origin(), 10, Color.BLUE));
+scene.addShape(myShape);
+#+end_src
+
+** Lighting and Shading
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.*;
+
+LightingManager lighting = viewPanel.getLightingManager();
+lighting.addLight(new LightSource(new Point3D(100, -100, 200), Color.YELLOW));
+lighting.setAmbientLight(new Color(20, 20, 20));
+
+// Enable shading on solid shapes
+SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 50, 16, Color.RED);
+sphere.setShadingEnabled(true);
+scene.addShape(sphere);
+#+end_src
+
+** CSG Operations (Boolean Operations)
+
+#+begin_src java
+// Create two shapes
+SolidPolygonCube box = new SolidPolygonCube(new Point3D(0, 0, 200), 50, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(new Point3D(0, 0, 200), 35, 16, Color.RED);
+
+// Subtract sphere from box (creates a box with spherical hole)
+box.subtract(sphere);
+scene.addShape(box);
+
+// Union: combine shapes
+// box.union(sphere);
+
+// Intersect: keep only overlapping parts
+// box.intersect(sphere);
+#+end_src
+
+** Text in 3D
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.aukio.e3d.gui.TextPointer;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+
+// Create text canvas
+Transform location = new Transform(new Point3D(0, 0, 500));
+TextCanvas canvas = new TextCanvas(location, "Hello World!", Color.WHITE, Color.BLACK);
+scene.addShape(canvas);
+
+// Or create blank canvas and write to it
+TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40), Color.GREEN, Color.BLACK);
+blank.locate(0, 0);  // row 0, column 0
+blank.print("Line 1");
+blank.locate(1, 0);
+blank.print("Line 2");
+blank.setForegroundColor(Color.YELLOW);
+blank.putChar('X');
+#+end_src
+
+** Animation with FrameListener
+
+#+begin_src java
+viewPanel.addFrameListener((panel, deltaMs) -> {
+    double rotationIncrement = deltaMs * 0.001;  // radians per ms
+
+    // Update shape transform
+    currentAngle += rotationIncrement;
+    myShape.setTransform(new Transform(
+        myShape.getLocation(),
+        currentAngle, 0  // yaw, pitch
+    ));
+
+    return true;  // return true to request repaint
+});
+#+end_src
+
+** Camera Control
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+
+Camera camera = viewPanel.getCamera();
+
+// Set position
+camera.getTransform().setTranslation(new Point3D(100, -50, -300));
+
+// Set orientation (quaternion from yaw/pitch angles)
+camera.getTransform().getRotation().set(Quaternion.fromAngles(0.5, -0.3));
+
+// Look at a specific point (convenience method)
+// camera.lookAt(new Point3D(0, 0, 200));
+#+end_src
+
+** Mouse Interaction on Shapes
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent;
+
+SolidPolygonCube clickableCube = new SolidPolygonCube(Point3D.origin(), 50, Color.BLUE);
+clickableCube.setMouseInteractionController(new MouseInteractionController() {
+    @Override
+    public void mouseClicked(final MouseEvent event) {
+        System.out.println("Cube clicked at: " + event.coordinate);
+    }
+
+    @Override
+    public void mouseEntered(final MouseEvent event) {
+        clickableCube.setColor(Color.RED);
+    }
+
+    @Override
+    public void mouseExited(final MouseEvent event) {
+        clickableCube.setColor(Color.BLUE);
+    }
+});
+scene.addShape(clickableCube);
+#+end_src
+
+* Class Catalog
+:PROPERTIES:
+:ID:       5522594a-969f-4cdb-b80e-d3d6bc732647
+:END:
+
+** Geometry (~geometry/~)
+
+| Class     | File           | Purpose                                                 | Key Methods                                                                                                       |
+|-----------+----------------+---------------------------------------------------------+-------------------------------------------------------------------------------------------------------------------|
+| ~Point3D~ | ~Point3D.java~ | Mutable 3D point/vector. *Public fields:* ~x~, ~y~, ~z~ | ~.add()~, ~.subtract()~, ~.multiply()~, ~.rotate()~, ~.getDistanceTo()~, ~.clone()~, ~.withAdded()~ (returns new) |
+| ~Point2D~ | ~Point2D.java~ | 2D screen coordinate                                    | ~.add()~, ~.subtract()~, ~.to3D()~                                                                                |
+| ~Box~     | ~Box.java~     | Axis-aligned bounding box                               | ~.getCenter()~, ~.enlarge()~, ~.intersectsAABB()~                                                                 |
+| ~Frustum~ | ~Frustum.java~ | View frustum (6 planes)                                 | ~.update()~, ~.intersectsAABB()~                                                                                  |
+| ~BspTree~ | ~BspTree.java~ | BSP tree for CSG                                        | ~.addPolygons()~, ~.clipPolygons()~, ~.invert()~, ~.allPolygons()~                                                |
+| ~Plane~   | ~Plane.java~   | Infinite plane (Hesse normal)                           | ~.fromPoints()~, ~.splitPolygon()~                                                                                |
+
+** Math (~math/~)
+
+| Class            | File                  | Purpose                       | Key Methods                                                                     |
+|------------------+-----------------------+-------------------------------+---------------------------------------------------------------------------------|
+| ~Transform~      | ~Transform.java~      | Translation + rotation        | ~.setTranslation()~, ~.transform(point)~, ~.withTransformed()~                  |
+| ~TransformStack~ | ~TransformStack.java~ | Stack of transforms           | ~.addTransform()~, ~.transform()~, ~.dropTransform()~                           |
+| ~Quaternion~     | ~Quaternion.java~     | 3D rotation (unit quaternion) | ~.fromAngles(yaw, pitch)~, ~.multiply()~, ~.invert()~, ~.toMatrix3x3()~         |
+| ~Vertex~         | ~Vertex.java~         | Wraps Point3D + transform     | ~.coordinate~, ~.transformedCoordinate~, ~.calculateLocationRelativeToViewer()~ |
+
+** Renderer Core (~renderer/raster/~)
+
+| Class              | File                    | Purpose                          | Key Methods                                                                                         |
+|--------------------+-------------------------+----------------------------------+-----------------------------------------------------------------------------------------------------|
+| ~Color~            | ~Color.java~            | RGBA color (NOT java.awt.Color!) | ~.set(r,g,b,a)~, ~.toAwtColor()~. Constants: ~RED~, ~GREEN~, ~BLUE~, ~BLACK~, ~WHITE~, ~CYAN~, etc. |
+| ~ShapeCollection~  | ~ShapeCollection.java~  | Root scene container             | ~.addShape()~, ~.hideGroup()~, ~.showGroup()~, ~.removeGroup()~                                     |
+| ~RenderAggregator~ | ~RenderAggregator.java~ | Collects, sorts, paints shapes   | ~.queueShapeForRendering()~, ~.sort()~, ~.paint()~                                                  |
+
+** Shapes - Base (~renderer/raster/shapes/~)
+
+| Class                     | File                                  | Purpose                                                         |
+|---------------------------+---------------------------------------+-----------------------------------------------------------------|
+| ~AbstractShape~           | ~shapes/AbstractShape.java~           | Base class for all shapes. Bounding box caching.                |
+| ~AbstractCoordinateShape~ | ~shapes/AbstractCoordinateShape.java~ | Base for shapes with vertices. Has ~List<Vertex>~, ~onScreenZ~. |
+
+** Shapes - Basic (~shapes/basic/~)
+
+| Class              | File                                          | Purpose                      | Constructor                                             |
+|--------------------+-----------------------------------------------+------------------------------+---------------------------------------------------------|
+| ~Line~             | ~basic/line/Line.java~                        | 3D line segment              | ~new Line(p1, p2, color, width)~                        |
+| ~LineAppearance~   | ~basic/line/LineAppearance.java~              | Factory for consistent lines | ~new LineAppearance(width, color)~ → ~.getLine(p1, p2)~ |
+| ~SolidPolygon~     | ~basic/solidpolygon/SolidPolygon.java~        | Solid convex N-gon           | ~SolidPolygon.triangle(...)~ or ~.quad(...)~            |
+| ~TexturedTriangle~ | ~basic/texturedpolygon/TexturedTriangle.java~ | Textured triangle with UV    | ~new TexturedTriangle(v1,v2,v3,texture)~                |
+| ~Billboard~        | ~basic/Billboard.java~                        | Texture facing camera        | ~new Billboard(position, scale, texture)~               |
+| ~GlowingPoint~     | ~basic/GlowingPoint.java~                     | Glowing circular point       | ~new GlowingPoint(position, scale, color)~              |
+
+** Shapes - Composite Wireframe (~shapes/composite/wireframe/~)
+
+| Class               | Constructor                                                 |
+|---------------------+-------------------------------------------------------------|
+| ~WireframeCube~     | ~new WireframeCube(center, halfSize, appearance)~           |
+| ~WireframeBox~      | ~new WireframeBox(corner1, corner2, appearance)~            |
+| ~WireframeSphere~   | ~new WireframeSphere(center, radius, appearance)~           |
+| ~WireframeCylinder~ | ~new WireframeCylinder(center, radius, height, appearance)~ |
+| ~WireframeCone~     | ~new WireframeCone(center, radius, height, appearance)~     |
+| ~WireframeArrow~    | ~new WireframeArrow(start, end, appearance)~                |
+| ~Grid2D~            | 2D grid plane                                               |
+| ~Grid3D~            | 3D grid in space                                            |
+
+** Shapes - Composite Solid (~shapes/composite/solid/~)
+
+| Class                        | Constructor                                               |
+|------------------------------+-----------------------------------------------------------|
+| ~SolidPolygonCube~           | ~new SolidPolygonCube(center, halfSize, color)~           |
+| ~SolidPolygonRectangularBox~ | ~new SolidPolygonRectangularBox(corner1, corner2, color)~ |
+| ~SolidPolygonSphere~         | ~new SolidPolygonSphere(center, radius, segments, color)~ |
+| ~SolidPolygonCylinder~       | Solid cylinder                                            |
+| ~SolidPolygonCone~           | Solid cone                                                |
+| ~SolidPolygonArrow~          | Solid arrow                                               |
+
+** Shapes - Composite Base (~shapes/composite/base/~)
+
+| Class                    | File                               | Purpose               | Key Methods                                                                                     |
+|--------------------------+------------------------------------+-----------------------+-------------------------------------------------------------------------------------------------|
+| ~AbstractCompositeShape~ | ~base/AbstractCompositeShape.java~ | Group shapes, CSG ops | ~.addShape()~, ~.subtract()~, ~.union()~, ~.intersect()~, ~.setShadingEnabled()~, ~.setColor()~ |
+
+** Text (~shapes/composite/textcanvas/~)
+
+| Class        | File                         | Purpose         | Key Methods                                                                       |
+|--------------+------------------------------+-----------------+-----------------------------------------------------------------------------------|
+| ~TextCanvas~ | ~textcanvas/TextCanvas.java~ | Text grid in 3D | ~.print()~, ~.locate(row,col)~, ~.clear()~, ~.setForegroundColor()~, ~.putChar()~ |
+
+** Texture (~renderer/raster/texture/~)
+
+| Class              | File                            | Purpose                                      |
+|--------------------+---------------------------------+----------------------------------------------|
+| ~Texture~          | ~texture/Texture.java~          | 2D texture with mipmaps                      |
+| ~TextureBitmap~    | ~texture/TextureBitmap.java~    | Raw pixel array for one mipmap level         |
+| ~TextureGenerator~ | ~texture/TextureGenerator.java~ | Factory for common textures (glows, borders) |
+
+** Lighting (~renderer/raster/lighting/~)
+
+| Class             | File                            | Purpose        | Key Methods                                               |
+|-------------------+---------------------------------+----------------+-----------------------------------------------------------|
+| ~LightingManager~ | ~lighting/LightingManager.java~ | Manages lights | ~.addLight()~, ~.setAmbientLight()~, ~.computeLighting()~ |
+| ~LightSource~     | ~lighting/LightSource.java~     | Point light    | ~new LightSource(position, color)~ → ~.setIntensity()~    |
+
+** GUI (~gui/~)
+
+| Class           | File                     | Purpose                      | Key Methods                                                                                            |
+|-----------------+--------------------------+------------------------------+--------------------------------------------------------------------------------------------------------|
+| ~ViewPanel~     | ~gui/ViewPanel.java~     | AWT Canvas, render loop      | ~.getRootShapeCollection()~, ~.getCamera()~, ~.getLightingManager()~, ~.addFrameListener()~, ~.stop()~ |
+| ~ViewFrame~     | ~gui/ViewFrame.java~     | JFrame wrapper               | ~new ViewFrame()~ → ~.getViewPanel()~                                                                  |
+| ~Camera~        | ~gui/Camera.java~        | Viewer position/orientation  | ~.getTransform()~, ~.setTransform()~                                                                   |
+| ~FrameListener~ | ~gui/FrameListener.java~ | Per-frame callback interface | ~.onFrame(panel, deltaMs)~ → return true to repaint                                                    |
+
+** Input (~gui/humaninput/~)
+
+| Class                        | File                                         | Purpose                        |
+|------------------------------+----------------------------------------------+--------------------------------|
+| ~InputManager~               | ~humaninput/InputManager.java~               | Mouse/keyboard tracking        |
+| ~MouseInteractionController~ | ~humaninput/MouseInteractionController.java~ | Interface for clickable shapes |
+| ~KeyboardFocusStack~         | ~humaninput/KeyboardFocusStack.java~         | Focus management for widgets   |
+
+** Octree Renderer (~renderer/octree/~)
+
+Alternative rendering path for voxel volumes with ray tracing.
+
+| Class          | File                              | Purpose                     |
+|----------------+-----------------------------------+-----------------------------|
+| ~OctreeVolume~ | ~octree/OctreeVolume.java~        | Sparse voxel octree storage |
+| ~RayTracer~    | ~octree/raytracer/RayTracer.java~ | Ray tracing renderer        |
+
+* Architecture & Key Concepts
+:PROPERTIES:
+:ID:       4be2246b-fcb6-4a1f-bcf8-7d657a67d957
+:END:
+
+** Coordinate System (CRITICAL)
+
+Aukio 3D uses *left-handed coordinates* matching 2D screen space:
+
+| Axis | Positive =  | Example                          |
+|------+-------------+----------------------------------|
+| X    | RIGHT       | Larger X = further right         |
+| Y    | DOWN        | Smaller Y = higher (up visually) |
+| Z    | INTO screen | Negative Z = closer to camera    |
+
+*To place A ABOVE B:* give A a *smaller Y* (~y - offset~)
+*To place A BELOW B:* give A a *larger Y* (~y + offset~)
+
+This is opposite to Y-up engines (OpenGL, Unity, Blender).
+
+** Shape Hierarchy
+
+#+begin_example
+AbstractShape (base)
+  ├── AbstractCoordinateShape (has vertices)
+  │     ├── Line
+  │     ├── SolidPolygon
+  │     ├── TexturedTriangle
+  │     ├── Billboard
+  │     └── GlowingPoint
+  └── AbstractCompositeShape (groups shapes)
+        ├── Wireframe shapes (WireframeCube, WireframeSphere, ...)
+        ├── Solid shapes (SolidPolygonCube, SolidPolygonSphere, ...)
+        └── TextCanvas
+#+end_example
+
+** Render Pipeline
+
+#+begin_example
+ViewPanel.renderFrame()
+  1. ShapeCollection.transformShapes() — apply camera transform
+  2. ShapeCollection.sortShapes() — sort by Z (back-to-front)
+  3. ShapeCollection.paintShapes() — painter's algorithm
+  4. BufferStrategy.show() — page flip to display
+#+end_example
+
+- Shapes implement ~transform()~ to project from world to screen space
+- Shapes implement ~paint()~ to rasterize to pixel buffer
+- ~onScreenZ~ determines render order (set during transform phase)
+
+** Backface Culling
+
+Uses signed area in screen space:
+- ~signedArea < 0~ → front-facing (CCW winding)
+- ~signedArea > 0~ → back-facing (CW winding)
+
+Vertex order for front face: *top → lower-left → lower-right* (as seen from camera)
+
+* Build & Test
+:PROPERTIES:
+:ID:       7909eb6a-c9a0-45f6-b1a5-a347e469c6cf
+:END:
+
+#+begin_src bash
+# Build
+mvn clean install
+
+# Run all tests
+mvn test
+
+# Run single test class
+mvn test -Dtest=TextLineTest
+
+# Run specific test method
+mvn test -Dtest=TextLineTest#testAddIdent
+
+# Golden-image regression tests (aukio-3d-demos repo)
+cd ../aukio-3d-demos && mvn clean package
+mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
+java -cp "target/classes:$(cat cp.txt)" \
+  eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens           # verify
+java -cp "target/classes:$(cat cp.txt)" \
+  eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens --update  # regenerate goldens
+
+# Regenerate all HTML documentation (org -> HTML, darksun theme)
+doc/export-docs.sh            # export
+doc/export-docs.sh --check    # export + headless-Chrome screenshots to /tmp
+#+end_src
+
+Test files: ~src/test/java/~ (JUnit 4)
+
+* Headless Testing Toolkit (~headless/~)
+:PROPERTIES:
+:ID:       b1f4a2c8-headless-toolkit
+:END:
+
+Windowless rendering and verification — no Swing frame, no X display,
+no render thread. Drives the same transform/sort/paint pipeline the
+on-screen path uses. Built for tests, doc tooling and AI agents.
+
+** Rendering a snapshot
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.headless.Snapshot;
+
+// Pose string = "x, y, z, yaw, pitch, roll" — the exact format demos
+// print and bug reports quote. Snapshot.cameraFromPose / poseString
+// convert both ways.
+BufferedImage image = Snapshot.render(scene, lighting,
+        "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480);
+Snapshot.save(image, "/tmp/snap.png");
+#+end_src
+
+~Snapshot.render(scene, lighting, camera, w, h)~ is the Camera-based
+overload; ~Snapshot.renderInto(scene, camera, ctx, backgroundArgb)~
+paints into an existing ~RenderingContext~ with a chosen background
+(sentinel color = "unpainted" for hole detection).
+
+** Pixel assertions
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.headless.PixelAssertions;
+
+double holes = PixelAssertions.unpaintedFraction(image, 0,
+        0.15, 0.45, 0.85, 1.0);   // lower-center band, relative coords
+long red = PixelAssertions.countColor(image, 0xFF0000);
+String grid = PixelAssertions.dumpPixelGrid(image, 320, 240, 3, 8);
+#+end_src
+
+** Golden-image comparison
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.headless.GoldenImage;
+
+GoldenImage.Result r = GoldenImage.compare(actual,
+        new File("goldens/house.png"), 4, 0.005);  // channel tol, max diff fraction
+GoldenImage.saveDiff(actual, goldenFile, "/tmp/diff.png");  // red = differs
+#+end_src
+
+CLI: ~java eu.svjatoslav.aukio.e3d.headless.GoldenImage a.png b.png [tol] [maxFrac]~
+(exit 0 = match, 1 = differ).
+
+The House demo has ready-made goldens in the aukio-3d-demos repo:
+~eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens~ checks two poses
+(default view + the near-plane straddle bug pose) against
+~aukio-3d-demos/goldens/*.png~ and asserts the floor has no holes.
+Run ~--update~ to regenerate after an intentional visual change.
+
+** Scene dump
+
+#+begin_src java
+import eu.svjatoslav.aukio.e3d.headless.SceneDump;
+
+System.out.println(SceneDump.dump(scene, lighting, camera, gi));
+// shapes: 5 top-level, 546 queued for rendering
+// lights: 4 (ambient #181818) + per-light pos/color/intensity
+// camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00
+// GI: running, 152034 work items, converged
+#+end_src
+
+** Engine API added for headless use
+
+- ~ShapeCollection.transformShapes(Camera, RenderingContext)~ — camera-based
+  overload; the ~ViewPanel~ variant delegates to it
+- ~ShapeCollection.getRootComposite()~ — direct root access (GI snapshots)
+- ~RenderingContext.getImage()~ — the backing BufferedImage
+- ~GlobalIllumination.isRunning() / isConverged() / getWorkItemCount()~
+
+** House demo scene without a window (aukio-3d-demos)
+
+~HouseDemo.buildHouse(house)~, ~HouseDemo.addFurniture(house)~ and
+~HouseDemo.addLights(lighting, scene)~ are public: tests and tools can
+rebuild the exact demo scene headlessly.
+
+* Tips for AI Agents
+:PROPERTIES:
+:ID:       597b2b14-1ee1-440b-bd40-ab7f3ed405de
+:END:
+
+1. *Always use project Color:* ~eu.svjatoslav.aukio.e3d.renderer.raster.Color~ (NOT ~java.awt.Color~)
+2. *Point3D is mutable:* Clone before storing references: ~point.clone()~
+3. *Y is down:* Remember coordinate system when positioning elements
+4. *SolidPolygon works for quads:* Use ~SolidPolygon.quad(p1,p2,p3,p4,color)~ - automatically triangulated
+5. *CSG on AbstractCompositeShape:* Only composite shapes support ~subtract()~, ~union()~, ~intersect()~
+6. *Animations via FrameListener:* Return ~true~ from ~onFrame()~ to trigger repaint
+7. *Shading needs lighting:* ~setShadingEnabled(true)~ + add ~LightSource~ to ~LightingManager~
+8. *Wireframe shapes need LineAppearance:* ~new LineAppearance(width, Color)~ for consistent line styling
+9. *Group visibility:* Use ~.addShape(shape, "groupName")~ then ~.hideGroup()~ / ~.showGroup()~
+
+* Documentation (Org Mode)
+:PROPERTIES:
+:ID:       1d942e3b-6071-440f-bd9b-876c8f7c53de
+:END:
+
+| Path                                         | Topic                                                               |
+|----------------------------------------------+---------------------------------------------------------------------|
+| ~doc/index.org~                              | Main: coordinate system, shapes, CSG, developer tools               |
+| ~doc/Rendering loop/index.org~               | 5-phase pipeline, multi-threaded paint                              |
+| ~doc/Shading/index.org~                      | Lambert shading, lights, distance attenuation                       |
+| ~doc/CSG/index.org~                          | Boolean ops via BSP trees                                           |
+| ~doc/Frustum culling/index.org~              | View frustum culling                                                |
+| ~doc/Near plane clip/index.org~              | Near-plane polygon clipping (straddling geometry)                   |
+| ~doc/Global illumination/index.org~          | Progressive GI: lightmaps, bounces, convergence                     |
+| ~doc/Perspective correct textures/index.org~ | Texture mapping math                                                |
+| ~doc/Agentic development/index.org~          | Stub: headless-toolkit docs moved to ~doc/index.org~ :: Agentic development |
+| ~doc/Stereoscopic rendering/index.org~       | Side-by-side stereo: two passes, per-eye viewports, IPD             |
+| ~doc/BSP-tree painter's algorithm/index.org~ | BSP compile + rank traversal fixing average-Z sort                  |
+| ~doc/SDF textures/index.org~                 | SDF text: glyph fields, coverage window, TextCanvas                 |
+
+Regenerate all HTML: ~doc/export-docs.sh~ (add ~--check~ for rendered
+screenshots of every page).
diff --git a/COPYING b/COPYING
new file mode 100644 (file)
index 0000000..0e259d4
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
diff --git a/TODO.org b/TODO.org
new file mode 100644 (file)
index 0000000..33129ba
--- /dev/null
+++ b/TODO.org
@@ -0,0 +1,93 @@
+* Add 3D mouse support
+:PROPERTIES:
+:CUSTOM_ID: add-3d-mouse-support
+:END:
+
+* Demos
+:PROPERTIES:
+:CUSTOM_ID: demos
+:END:
+** Add more math formula examples to "Mathematical formulas" demo
+:PROPERTIES:
+:CUSTOM_ID: add-more-math-formula-examples
+:END:
+
+* Performance
+:PROPERTIES:
+:CUSTOM_ID: performance
+:END:
+** Group identical Vertices into one during object slicing
+Now system will need to compute each unique point in 3D only
+once. Polygons can share coordinates.
+
+** Add dynamic resolution support
+:PROPERTIES:
+:CUSTOM_ID: add-dynamic-resolution-support
+:END:
++ When there are fast-paced scenes, dynamically and temporarily reduce
+  image resolution if needed to maintain desired FPS.
+
+** Add object fading based on view distance
+:PROPERTIES:
+:CUSTOM_ID: add-object-fading-view-distance
+:END:
+Goal: make it easier to distinguish nearby objects from distant ones.
+
+** Add polygon reduction based on view distance (LOD)
+:PROPERTIES:
+:CUSTOM_ID: add-polygon-reduction-lod
+:END:
+
+** Compute global illumination at progressive resolutions
+
+At startup, initially compute global illumination using very coarse
+lightmap. Then progressively keep recomputing it with finer and finer
+level of detail.  Once lightmap stabilizes, stop lightmap computation
+until there is change in the scene.
+
+* Features
+:PROPERTIES:
+:CUSTOM_ID: features
+:END:
+** Make it possible to configure field of view (FOV)
+** Add collision detection (physics engine)
+* Add clickable vertexes
+:PROPERTIES:
+:CUSTOM_ID: add-clickable-vertexes
+:END:
+
+Circular areas with radius. Can be visible, partially transparent or
+invisible.
+
+Use them in 3D graph demo. Clicking on vertexes should place marker
+and information billboard showing values at given XYZ location.
+
+Add formula textbox display on top of 3D graph.
+- Consider making separate formula explorer app where formula will be
+  editable and there will be gallery of pre-vetted formulas.
+  - make this app under Aukio parent project.
+    - Consider integrating with FriCAS or similar CAS software so that
+      formula parsing and computation happens there.
+
++ Study and apply where applicable
+
++ Read this as example, and apply improvements/fixes where applicable:
+  http://blog.rogach.org/2015/08/how-to-create-your-own-simple-3d-render.html
+
++ Improve triangulation. Read: https://ianthehenry.com/posts/delaunay/
+
+* Aukio 3D Demos
+
+** Text editors demo
+
++ Improve focus handling:
+  + Perhaps add shortcut to navigate world without exiting entire
+    stack of focus.
+  + Possibility to retain and reuse recently focused elements.
+  + Store user location in the world and view direction with the
+    focused window. So that when returning focus to far away object,
+    user is redirected also to proper location in the world.
+
++ Possibility to store recently visited locations in the world and
+  return to them.
+
diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA
new file mode 100755 (executable)
index 0000000..304bf94
--- /dev/null
@@ -0,0 +1,54 @@
+#!/bin/bash
+
+# This script launches IntelliJ IDEA with the current project
+# directory. The script is designed to be run by double-clicking it in
+# the GNOME Nautilus file manager.
+
+# First, we change the current working directory to the directory of
+# the script.
+
+# "${0%/*}" gives us the path of the script itself, without the
+# script's filename.
+
+# This command basically tells the system "change the current
+# directory to the directory containing this script".
+
+cd "${0%/*}"
+
+# Then, we move up one directory level.
+# The ".." tells the system to go to the parent directory of the current directory.
+# This is done because we assume that the project directory is one level up from the script.
+cd ..
+
+# Now, we use the 'setsid' command to start a new session and run
+# IntelliJ IDEA in the background. 'setsid' is a UNIX command that
+# runs a program in a new session.
+
+# The command 'idea .' opens IntelliJ IDEA with the current directory
+# as the project directory.  The '&' at the end is a UNIX command that
+# runs the process in the background.  The '> /dev/null' part tells
+# the system to redirect all output (both stdout and stderr, denoted
+# by '&') that would normally go to the terminal to go to /dev/null
+# instead, which is a special file that discards all data written to
+# it.
+
+setsid idea . &>/dev/null &
+
+# The 'disown' command is a shell built-in that removes a shell job
+# from the shell's active list. Therefore, the shell will not send a
+# SIGHUP to this particular job when the shell session is terminated.
+
+# '-h' option specifies that if the shell receives a SIGHUP, it also
+# doesn't send a SIGHUP to the job.
+
+# '$!' is a shell special parameter that expands to the process ID of
+# the most recent background job.
+disown -h $!
+
+
+sleep 2
+
+# Finally, we use the 'exit' command to terminate the shell script.
+# This command tells the system to close the terminal window after
+# IntelliJ IDEA has been opened.
+exit
diff --git a/Tools/Update web site b/Tools/Update web site
new file mode 100755 (executable)
index 0000000..e80b377
--- /dev/null
@@ -0,0 +1,101 @@
+#!/bin/bash
+cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi;
+
+cd ..
+
+# Function to export org to html using emacs in batch mode
+export_org_to_html() {
+    local org_file=$1
+    local dir=$(dirname "$org_file")
+    local base=$(basename "$org_file" .org)
+    (
+        cd "$dir" || return 1
+        local html_file="${base}.html"
+        if [ -f "$html_file" ]; then
+            rm -f "$html_file"
+        fi
+        echo "Exporting: $org_file → $dir/$html_file"
+        emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill
+        if [ $? -eq 0 ]; then
+            echo "✓ Successfully exported $org_file"
+        else
+            echo "✗ Failed to export $org_file"
+            return 1
+        fi
+    )
+}
+
+export_org_files_to_html() {
+    echo "🔍 Searching for .org files in doc/ ..."
+    echo "======================================="
+
+    mapfile -t ORG_FILES < <(find doc -type f -name "*.org" | sort)
+
+    if [ ${#ORG_FILES[@]} -eq 0 ]; then
+        echo "❌ No .org files found!"
+        return 1
+    fi
+
+    echo "Found ${#ORG_FILES[@]} .org file(s):"
+    printf '%s\n' "${ORG_FILES[@]}"
+    echo "======================================="
+
+    SUCCESS_COUNT=0
+    FAILED_COUNT=0
+
+    for org_file in "${ORG_FILES[@]}"; do
+        export_org_to_html "$org_file"
+        if [ $? -eq 0 ]; then
+            ((SUCCESS_COUNT++))
+        else
+            ((FAILED_COUNT++))
+        fi
+    done
+
+    echo "======================================="
+    echo "📊 SUMMARY:"
+    echo "   ✓ Successful: $SUCCESS_COUNT"
+    echo "   ✗ Failed:     $FAILED_COUNT"
+    echo "   Total:        $((SUCCESS_COUNT + FAILED_COUNT))"
+    echo ""
+}
+
+build_visualization_graphs() {
+    rm -rf doc/graphs/
+    mkdir -p doc/graphs/
+
+    javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "All classes" -t png -ho
+    javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "GUI" -t png -w "eu.svjatoslav.aukio.e3d.gui.*" -ho
+    javainspect -j target/aukio-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "Raster engine" -t png -w "eu.svjatoslav.aukio.e3d.renderer.raster.*" -ho
+
+    meviz index -w doc/graphs/ -t "Aukio 3D classes"
+}
+
+# Build project jar file and JavaDocs
+mvn clean package
+
+# Put generated JavaDoc HTML files to documentation directory
+rm -rf doc/apidocs/
+cp -r target/apidocs/ doc/
+
+# Publish Emacs org-mode files into HTML format
+export_org_files_to_html
+
+# Generate nice looking code visualization diagrams
+build_visualization_graphs
+
+
+## Upload assembled documentation to server
+echo "📤 Uploading to server..."
+rsync -avz --delete -e 'ssh -p 10006' doc/ \
+      n0@www3.svjatoslav.eu:/mnt/big/projects/aukio-3d/
+
+if [ $? -eq 0 ]; then
+    echo "✓ Upload completed successfully!"
+else
+    echo "✗ Upload failed!"
+fi
+
+echo ""
+echo "Press ENTER to close this window."
+read
diff --git a/doc/Agentic development/Golden workflow.svg b/doc/Agentic development/Golden workflow.svg
new file mode 100644 (file)
index 0000000..aa0e7fb
--- /dev/null
@@ -0,0 +1,74 @@
+<svg viewBox="0 0 620 190" width="620" height="190" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
+      <path d="M0 0 L8 4 L0 8 Z" fill="#40b0d0"/>
+    </marker>
+    <marker id="arrRed" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
+      <path d="M0 0 L8 4 L0 8 Z" fill="#FF4444"/>
+    </marker>
+  </defs>
+
+  <rect width="620" height="190" fill="#061018"/>
+  <g stroke="#1a3a4a" stroke-width="0.5">
+    <line x1="0" y1="40" x2="620" y2="40"/><line x1="0" y1="80" x2="620" y2="80"/>
+    <line x1="0" y1="120" x2="620" y2="120"/><line x1="0" y1="160" x2="620" y2="160"/>
+    <line x1="40" y1="0" x2="40" y2="190"/><line x1="80" y1="0" x2="80" y2="190"/>
+    <line x1="120" y1="0" x2="120" y2="190"/><line x1="160" y1="0" x2="160" y2="190"/>
+    <line x1="200" y1="0" x2="200" y2="190"/><line x1="240" y1="0" x2="240" y2="190"/>
+    <line x1="280" y1="0" x2="280" y2="190"/><line x1="320" y1="0" x2="320" y2="190"/>
+    <line x1="360" y1="0" x2="360" y2="190"/><line x1="400" y1="0" x2="400" y2="190"/>
+    <line x1="440" y1="0" x2="440" y2="190"/><line x1="480" y1="0" x2="480" y2="190"/>
+    <line x1="520" y1="0" x2="520" y2="190"/><line x1="560" y1="0" x2="560" y2="190"/>
+    <line x1="600" y1="0" x2="600" y2="190"/>
+  </g>
+
+  <!-- step 1: render -->
+  <rect x="20" y="55" width="110" height="60" rx="8" fill="rgba(32,112,192,0.12)" stroke="#2070c0" stroke-width="2"/>
+  <text x="75" y="80" fill="#2070c0" font-size="10" font-family="monospace" text-anchor="middle">Snapshot.render</text>
+  <text x="75" y="97" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">scene + pose</text>
+
+  <line x1="130" y1="85" x2="158" y2="85" stroke="#40b0d0" stroke-width="2" marker-end="url(#arr)"/>
+
+  <!-- step 2: golden file -->
+  <rect x="160" y="20" width="110" height="40" rx="8" fill="rgba(192,80,136,0.12)" stroke="#c05088" stroke-width="2"/>
+  <text x="215" y="38" fill="#c05088" font-size="10" font-family="monospace" text-anchor="middle">goldens/*.png</text>
+  <text x="215" y="52" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">committed reference</text>
+  <line x1="215" y1="60" x2="215" y2="80" stroke="#c05088" stroke-width="1.5" stroke-dasharray="3 2" marker-end="url(#arr)"/>
+
+  <!-- step 3: compare decision -->
+  <polygon points="215,85 265,55 315,85 265,115" fill="rgba(64,176,208,0.12)" stroke="#40b0d0" stroke-width="2"/>
+  <text x="265" y="82" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle">GoldenImage</text>
+  <text x="265" y="95" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle">.compare</text>
+
+  <!-- PASS branch -->
+  <line x1="315" y1="85" x2="348" y2="85" stroke="#39FF14" stroke-width="2" marker-end="url(#arr)"/>
+  <rect x="350" y="60" width="90" height="50" rx="8" fill="rgba(57,255,20,0.12)" stroke="#39FF14" stroke-width="2"/>
+  <text x="395" y="82" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">PASS</text>
+  <text x="395" y="98" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">exit 0</text>
+
+  <!-- FAIL branch -->
+  <path d="M265 115 L265 140 L 348 140" stroke="#FF4444" stroke-width="2" fill="none" marker-end="url(#arrRed)"/>
+  <rect x="350" y="115" width="120" height="50" rx="8" fill="rgba(255,68,68,0.1)" stroke="#FF4444" stroke-width="2"/>
+  <text x="410" y="136" fill="#FF4444" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">FAIL, exit 1</text>
+  <text x="410" y="152" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">+ diff PNG to /tmp</text>
+
+  <!-- human decision -->
+  <line x1="470" y1="140" x2="498" y2="140" stroke="#FF4444" stroke-width="2" marker-end="url(#arrRed)"/>
+  <rect x="500" y="115" width="100" height="50" rx="8" fill="rgba(255,136,51,0.1)" stroke="#FF8833" stroke-width="2"/>
+  <text x="550" y="133" fill="#FF8833" font-size="9" font-family="monospace" text-anchor="middle">bug? fix code</text>
+  <text x="550" y="146" fill="#FF8833" font-size="9" font-family="monospace" text-anchor="middle">intended? run</text>
+  <text x="550" y="158" fill="#FF8833" font-size="9" font-family="monospace" text-anchor="middle">--update</text>
+
+  <!-- update loop back to golden -->
+  <path d="M550 115 L 550 30 L 272 30" stroke="#FF8833" stroke-width="1.5" fill="none" stroke-dasharray="4 3" marker-end="url(#arr)"/>
+  <text x="420" y="22" fill="#FF8833" font-size="9" font-family="monospace" text-anchor="middle">regenerates reference</text>
+
+  <text x="310" y="182" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">tolerance: per-channel delta + max differing-pixel fraction — shading is deterministic, keep both tight</text>
+</svg>
diff --git a/doc/Agentic development/Headless lanes.svg b/doc/Agentic development/Headless lanes.svg
new file mode 100644 (file)
index 0000000..1303a80
--- /dev/null
@@ -0,0 +1,78 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
+      <path d="M0 0 L8 4 L0 8 Z" fill="#40b0d0"/>
+    </marker>
+  </defs>
+
+  <rect width="640" height="480" fill="#061018"/>
+  <g stroke="#1a3a4a" stroke-width="0.5">
+    <line x1="0" y1="40" x2="640" y2="40"/><line x1="0" y1="80" x2="640" y2="80"/>
+    <line x1="0" y1="120" x2="640" y2="120"/><line x1="0" y1="160" x2="640" y2="160"/>
+    <line x1="0" y1="200" x2="640" y2="200"/><line x1="0" y1="240" x2="640" y2="240"/>
+    <line x1="0" y1="280" x2="640" y2="280"/><line x1="0" y1="320" x2="640" y2="320"/>
+    <line x1="0" y1="360" x2="640" y2="360"/><line x1="0" y1="400" x2="640" y2="400"/>
+    <line x1="0" y1="440" x2="640" y2="440"/>
+    <line x1="40" y1="0" x2="40" y2="480"/><line x1="80" y1="0" x2="80" y2="480"/>
+    <line x1="120" y1="0" x2="120" y2="480"/><line x1="160" y1="0" x2="160" y2="480"/>
+    <line x1="200" y1="0" x2="200" y2="480"/><line x1="240" y1="0" x2="240" y2="480"/>
+    <line x1="280" y1="0" x2="280" y2="480"/><line x1="320" y1="0" x2="320" y2="480"/>
+    <line x1="360" y1="0" x2="360" y2="480"/><line x1="400" y1="0" x2="400" y2="480"/>
+    <line x1="440" y1="0" x2="440" y2="480"/><line x1="480" y1="0" x2="480" y2="480"/>
+    <line x1="520" y1="0" x2="520" y2="480"/><line x1="560" y1="0" x2="560" y2="480"/>
+    <line x1="600" y1="0" x2="600" y2="480"/>
+  </g>
+
+  <!-- ============ two entry lanes feeding ONE shared pipeline ============ -->
+
+  <!-- lane 1: on-screen app -->
+  <rect x="40" y="60" width="200" height="66" rx="8" fill="rgba(255,136,51,0.12)" stroke="#FF8833" stroke-width="2"/>
+  <text x="140" y="86" fill="#FF8833" font-size="12" font-family="monospace" text-anchor="middle" filter="url(#glow)">ViewPanel</text>
+  <text x="140" y="103" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">window + render thread</text>
+  <text x="140" y="116" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">camera from user input</text>
+
+  <!-- lane 2: headless -->
+  <rect x="40" y="354" width="200" height="66" rx="8" fill="rgba(57,255,20,0.1)" stroke="#39FF14" stroke-width="2"/>
+  <text x="140" y="380" fill="#39FF14" font-size="12" font-family="monospace" text-anchor="middle" filter="url(#glow)">Snapshot.render()</text>
+  <text x="140" y="397" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">no window, no display</text>
+  <text x="140" y="410" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">camera from pose string</text>
+
+  <!-- shared pipeline core -->
+  <rect x="300" y="170" width="140" height="140" rx="10" fill="rgba(32,112,192,0.12)" stroke="#2070c0" stroke-width="2.5"/>
+  <text x="370" y="196" fill="#2070c0" font-size="13" font-family="monospace" text-anchor="middle" filter="url(#glow)">SAME pipeline</text>
+  <text x="370" y="228" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">transform</text>
+  <text x="370" y="248" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">&#8595;</text>
+  <text x="370" y="266" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">sort by Z</text>
+  <text x="370" y="284" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">&#8595;</text>
+  <text x="370" y="302" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">paint</text>
+
+  <!-- outputs -->
+  <rect x="500" y="60" width="110" height="50" rx="8" fill="rgba(255,136,51,0.08)" stroke="#FF8833" stroke-width="1.5"/>
+  <text x="555" y="81" fill="#FF8833" font-size="11" font-family="monospace" text-anchor="middle">screen</text>
+  <text x="555" y="97" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">BufferStrategy</text>
+
+  <rect x="490" y="330" width="130" height="90" rx="8" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5"/>
+  <text x="555" y="352" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">BufferedImage</text>
+  <text x="555" y="370" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">&#8594; PNG (Snapshot.save)</text>
+  <text x="555" y="385" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">&#8594; PixelAssertions</text>
+  <text x="555" y="400" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">&#8594; GoldenImage</text>
+
+  <!-- lane arrows converging into the core -->
+  <path d="M240 93 C 290 93, 270 210, 298 225" stroke="#40b0d0" stroke-width="2" fill="none" marker-end="url(#arr)"/>
+  <path d="M240 387 C 290 387, 270 270, 298 255" stroke="#40b0d0" stroke-width="2" fill="none" marker-end="url(#arr)"/>
+
+  <!-- output arrows -->
+  <path d="M440 210 C 480 210, 460 90, 498 88" stroke="#40b0d0" stroke-width="2" fill="none" marker-end="url(#arr)"/>
+  <path d="M440 270 C 480 270, 460 372, 488 374" stroke="#40b0d0" stroke-width="2" fill="none" marker-end="url(#arr)"/>
+
+  <!-- emphasis labels -->
+  <text x="270" y="150" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle">identical results</text>
+  <text x="320" y="462" fill="#aaa" font-size="10" font-family="monospace" text-anchor="middle">headless rendering drives the very same code the window uses — a test render IS the real render</text>
+</svg>
diff --git a/doc/Agentic development/Pixel assertion.svg b/doc/Agentic development/Pixel assertion.svg
new file mode 100644 (file)
index 0000000..a9218f6
--- /dev/null
@@ -0,0 +1,65 @@
+<svg viewBox="0 0 620 300" width="620" height="300" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
+      <path d="M0 0 L8 4 L0 8 Z" fill="#40b0d0"/>
+    </marker>
+  </defs>
+
+  <rect width="620" height="300" fill="#061018"/>
+  <g stroke="#1a3a4a" stroke-width="0.5">
+    <line x1="0" y1="40" x2="620" y2="40"/><line x1="0" y1="80" x2="620" y2="80"/>
+    <line x1="0" y1="120" x2="620" y2="120"/><line x1="0" y1="160" x2="620" y2="160"/>
+    <line x1="0" y1="200" x2="620" y2="200"/><line x1="0" y1="240" x2="620" y2="240"/>
+    <line x1="0" y1="280" x2="620" y2="280"/>
+    <line x1="40" y1="0" x2="40" y2="300"/><line x1="80" y1="0" x2="80" y2="300"/>
+    <line x1="120" y1="0" x2="120" y2="300"/><line x1="160" y1="0" x2="160" y2="300"/>
+    <line x1="200" y1="0" x2="200" y2="300"/><line x1="240" y1="0" x2="240" y2="300"/>
+    <line x1="280" y1="0" x2="280" y2="300"/><line x1="320" y1="0" x2="320" y2="300"/>
+    <line x1="360" y1="0" x2="360" y2="300"/><line x1="400" y1="0" x2="400" y2="300"/>
+    <line x1="440" y1="0" x2="440" y2="300"/><line x1="480" y1="0" x2="480" y2="300"/>
+    <line x1="520" y1="0" x2="520" y2="300"/><line x1="560" y1="0" x2="560" y2="300"/>
+    <line x1="600" y1="0" x2="600" y2="300"/>
+  </g>
+
+  <!-- fake rendered frame: sentinel background + painted shapes -->
+  <rect x="60" y="40" width="320" height="220" fill="#0a1520" stroke="#40b0d0" stroke-width="2"/>
+  <text x="220" y="32" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">rendered frame (640&#215;480)</text>
+
+  <!-- painted content: simple house-like blocks -->
+  <rect x="90" y="120" width="80" height="100" fill="rgba(192,80,136,0.55)" stroke="#c05088" stroke-width="1.5"/>
+  <rect x="190" y="80" width="120" height="60" fill="rgba(32,112,192,0.45)" stroke="#2070c0" stroke-width="1.5"/>
+  <rect x="190" y="160" width="150" height="80" fill="rgba(192,80,136,0.35)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="130" y="175" fill="#e090b8" font-size="9" font-family="monospace" text-anchor="middle">painted</text>
+  <text x="255" y="205" fill="#e090b8" font-size="9" font-family="monospace" text-anchor="middle">painted</text>
+
+  <!-- the probed region: relative rect -->
+  <rect x="108" y="139" width="224" height="121" fill="none" stroke="#39FF14" stroke-width="2.5" stroke-dasharray="8 4"/>
+  <text x="338" y="132" fill="#39FF14" font-size="9" font-family="monospace" text-anchor="end" filter="url(#glow)">region (0.15, 0.45) &#8594; (0.85, 1.0)</text>
+
+  <!-- a hole: sentinel showing through -->
+  <rect x="225" y="200" width="45" height="30" fill="#000000" stroke="#FF4444" stroke-width="2" stroke-dasharray="4 3"/>
+  <text x="247" y="218" fill="#FF4444" font-size="9" font-family="monospace" text-anchor="middle">hole</text>
+
+  <!-- right: the assertion code and verdict -->
+  <rect x="410" y="60" width="195" height="150" rx="8" fill="rgba(64,176,208,0.08)" stroke="#40b0d0" stroke-width="1.5"/>
+  <text x="508" y="80" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle" filter="url(#glow)">PixelAssertions</text>
+  <text x="420" y="102" fill="#ccc" font-size="9" font-family="monospace">unpaintedFraction(img,</text>
+  <text x="420" y="116" fill="#ccc" font-size="9" font-family="monospace">  bg=0x000000,</text>
+  <text x="420" y="130" fill="#ccc" font-size="9" font-family="monospace">  0.15, 0.45,</text>
+  <text x="420" y="144" fill="#ccc" font-size="9" font-family="monospace">  0.85, 1.0)</text>
+  <text x="420" y="168" fill="#999" font-size="9" font-family="monospace">counts pixels that still</text>
+  <text x="420" y="181" fill="#999" font-size="9" font-family="monospace">equal the background</text>
+  <text x="420" y="200" fill="#FF4444" font-size="10" font-family="monospace">&#8594; 0.017 &gt; 0.01 FAIL</text>
+
+  <line x1="380" y1="150" x2="408" y2="140" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+
+  <!-- bottom notes -->
+  <text x="60" y="282" fill="#999" font-size="9" font-family="monospace">sentinel background: "nothing rendered here" is unambiguous, even in dark scenes</text>
+</svg>
diff --git a/doc/Agentic development/diff-example.png b/doc/Agentic development/diff-example.png
new file mode 100644 (file)
index 0000000..941d5ca
Binary files /dev/null and b/doc/Agentic development/diff-example.png differ
diff --git a/doc/Agentic development/snapshot-example.png b/doc/Agentic development/snapshot-example.png
new file mode 100644 (file)
index 0000000..92ea03c
Binary files /dev/null and b/doc/Agentic development/snapshot-example.png differ
diff --git a/doc/CSG/BSP tree.svg b/doc/CSG/BSP tree.svg
new file mode 100644 (file)
index 0000000..eb89c2c
--- /dev/null
@@ -0,0 +1,45 @@
+<svg viewBox="0 0 620 220" width="620" height="220" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="bsp-glow"><feGaussianBlur stdDeviation="1.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  </defs>
+  <rect width="620" height="220" fill="#061018"/>
+
+  <!-- ── Root node ── -->
+  <rect x="250" y="18" width="120" height="32" rx="4" fill="rgba(64,176,208,0.15)" stroke="#40b0d0" stroke-width="1.5"/>
+  <text x="310" y="39" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#bsp-glow)">Plane P₁</text>
+
+  <!-- ── Connectors: root → children ── -->
+  <line x1="310" y1="50" x2="310" y2="62" stroke="#40b0d0" stroke-width="1"/>
+  <line x1="310" y1="62" x2="160" y2="62" stroke="#40b0d0" stroke-width="1"/>
+  <line x1="310" y1="62" x2="460" y2="62" stroke="#40b0d0" stroke-width="1"/>
+  <line x1="160" y1="62" x2="160" y2="72" stroke="#40b0d0" stroke-width="1"/>
+  <line x1="460" y1="62" x2="460" y2="72" stroke="#40b0d0" stroke-width="1"/>
+  <!-- Branch labels -->
+  <text x="225" y="58" fill="#30a050" font-size="8" font-family="monospace" text-anchor="middle">front</text>
+  <text x="395" y="58" fill="#d04040" font-size="8" font-family="monospace" text-anchor="middle">back</text>
+
+  <!-- ── Front child ── -->
+  <rect x="100" y="72" width="120" height="32" rx="4" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="160" y="93" fill="#30a050" font-size="11" font-family="monospace" text-anchor="middle">Front (P₂)</text>
+
+  <!-- ── Back child ── -->
+  <rect x="400" y="72" width="120" height="32" rx="4" fill="rgba(208,64,64,0.15)" stroke="#d04040" stroke-width="1.5"/>
+  <text x="460" y="93" fill="#d04040" font-size="11" font-family="monospace" text-anchor="middle">Back (P₃)</text>
+
+  <!-- ── Connectors: front child → grandchildren ── -->
+  <line x1="160" y1="104" x2="160" y2="116" stroke="#30a050" stroke-width="1"/>
+  <line x1="160" y1="116" x2="85" y2="116" stroke="#30a050" stroke-width="1"/>
+  <line x1="160" y1="116" x2="235" y2="116" stroke="#30a050" stroke-width="1"/>
+  <line x1="85" y1="116" x2="85" y2="126" stroke="#30a050" stroke-width="1"/>
+  <line x1="235" y1="116" x2="235" y2="126" stroke="#30a050" stroke-width="1"/>
+
+  <!-- ── Leaf nodes ── -->
+  <rect x="45" y="126" width="80" height="28" rx="4" fill="rgba(100,100,100,0.08)" stroke="#aaa" stroke-width="1" stroke-dasharray="3 2"/>
+  <text x="85" y="145" fill="#bbb" font-size="10" font-family="monospace" text-anchor="middle">leaf</text>
+  <rect x="195" y="126" width="80" height="28" rx="4" fill="rgba(100,100,100,0.08)" stroke="#aaa" stroke-width="1" stroke-dasharray="3 2"/>
+  <text x="235" y="145" fill="#bbb" font-size="10" font-family="monospace" text-anchor="middle">leaf</text>
+
+  <!-- ── Explanation ── -->
+  <text x="310" y="185" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Each plane divides space into front (normal side) and back (opposite)</text>
+  <text x="310" y="200" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Polygons are classified and split at each partitioning plane</text>
+</svg>
diff --git a/doc/CSG/CSG demo.png b/doc/CSG/CSG demo.png
new file mode 100644 (file)
index 0000000..2275350
Binary files /dev/null and b/doc/CSG/CSG demo.png differ
diff --git a/doc/CSG/CSG intersect.svg b/doc/CSG/CSG intersect.svg
new file mode 100644 (file)
index 0000000..a912f81
--- /dev/null
@@ -0,0 +1,41 @@
+<svg viewBox="0 0 620 170" width="620" height="170" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="i-glow"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+    <clipPath id="i-clip-rect"><rect x="370" y="35" width="80" height="80"/></clipPath>
+    <clipPath id="i-clip-circ"><circle cx="450" cy="75" r="42"/></clipPath>
+  </defs>
+  <rect width="620" height="170" fill="#061018"/>
+
+  <!-- ── Input ── -->
+  <text x="110" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Input</text>
+  <rect x="50" y="35" width="80" height="80" rx="2" fill="rgba(57,255,20,0.2)" stroke="#39FF14" stroke-width="2"/>
+  <text x="90" y="80" fill="#39FF14" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">A</text>
+  <circle cx="130" cy="75" r="42" fill="rgba(255,102,0,0.2)" stroke="#FF6600" stroke-width="2"/>
+  <text x="148" y="80" fill="#FF6600" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">B</text>
+
+  <!-- ── Operator ── -->
+  <text x="250" y="70" fill="#40b0d0" font-size="22" font-family="monospace" text-anchor="middle" filter="url(#i-glow)">∩</text>
+  <text x="250" y="92" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">intersect</text>
+
+  <!-- ── Arrow ── -->
+  <line x1="280" y1="78" x2="330" y2="78" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <polygon points="330,73 340,78 330,83" fill="#40b0d0"/>
+
+  <!-- ── Result: only the overlap region ── -->
+  <text x="440" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Result: A ∩ B</text>
+  <!-- Ghost outlines of original shapes (faint) -->
+  <rect x="370" y="35" width="80" height="80" rx="2" fill="none" stroke="#39FF14" stroke-width="0.5" opacity="0.2" stroke-dasharray="4 3"/>
+  <circle cx="450" cy="75" r="42" fill="none" stroke="#FF6600" stroke-width="0.5" opacity="0.2" stroke-dasharray="4 3"/>
+  <!-- Intersection fill: lens shape = circle arc clipped to rect -->
+  <circle cx="450" cy="75" r="42" fill="rgba(57,255,20,0.08)" stroke="none" clip-path="url(#i-clip-rect)"/>
+  <!-- Left boundary: arc from circle (orange, from B) -->
+  <path d="M450,33 A42,42 0 0,0 450,117" fill="none" stroke="#FF6600" stroke-width="1.5" clip-path="url(#i-clip-rect)"/>
+  <!-- Right boundary: straight edge from rect (green, from A) -->
+  <line x1="450" y1="35" x2="450" y2="115" stroke="#39FF14" stroke-width="1.5" clip-path="url(#i-clip-circ)"/>
+
+  <!-- ── Descriptions ── -->
+  <text x="110" y="142" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Find overlap</text>
+  <text x="110" y="155" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">between both</text>
+  <text x="440" y="142" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Only shared volume</text>
+  <text x="440" y="155" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">remains</text>
+</svg>
diff --git a/doc/CSG/CSG operations.svg b/doc/CSG/CSG operations.svg
new file mode 100644 (file)
index 0000000..3f73cbe
--- /dev/null
@@ -0,0 +1,37 @@
+<svg viewBox="0 0 620 170" width="620" height="170" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="s-glow"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+    <clipPath id="s-cube"><rect x="370" y="35" width="80" height="80"/></clipPath>
+  </defs>
+  <rect width="620" height="170" fill="#061018"/>
+
+  <!-- ── Input ── -->
+  <text x="110" y="20" fill="#aaa" font-size="11" font-family="monospace" text-anchor="middle">Input</text>
+  <rect x="50" y="35" width="80" height="80" rx="2" fill="rgba(57,255,20,0.2)" stroke="#39FF14" stroke-width="2"/>
+  <text x="90" y="80" fill="#39FF14" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">A</text>
+  <circle cx="130" cy="75" r="42" fill="rgba(255,102,0,0.12)" stroke="#FF6600" stroke-width="2" stroke-dasharray="5 3"/>
+  <text x="148" y="80" fill="#FF6600" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">B</text>
+
+  <!-- ── Operator ── -->
+  <text x="250" y="70" fill="#40b0d0" font-size="22" font-family="monospace" text-anchor="middle" filter="url(#s-glow)">−</text>
+  <text x="250" y="92" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">subtract</text>
+
+  <!-- ── Arrow ── -->
+  <line x1="280" y1="78" x2="330" y2="78" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <polygon points="330,73 340,78 330,83" fill="#40b0d0"/>
+
+  <!-- ── Result ── -->
+  <text x="440" y="20" fill="#aaa" font-size="11" font-family="monospace" text-anchor="middle">Result: A − B</text>
+  <!-- Cube body -->
+  <rect x="370" y="35" width="80" height="80" rx="2" fill="rgba(57,255,20,0.15)" stroke="#39FF14" stroke-width="1.5"/>
+  <!-- Cavity: dark hole with single solid arc edge -->
+  <circle cx="450" cy="75" r="42" fill="rgba(6,16,24,0.8)" stroke="none" clip-path="url(#s-cube)"/>
+  <path d="M450,33 A42,42 0 0,0 450,117" fill="none" stroke="#FF6600" stroke-width="1.5" clip-path="url(#s-cube)"/>
+  <text x="425" y="78" fill="#40b0d0" font-size="7" font-family="monospace" text-anchor="middle" opacity="0.7">cavity</text>
+
+  <!-- ── Descriptions ── -->
+  <text x="110" y="142" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">B is the "cutter"</text>
+  <text x="110" y="155" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">carves out of A</text>
+  <text x="440" y="142" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">Cube with cavity</text>
+  <text x="440" y="155" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">interior faces visible</text>
+</svg>
diff --git a/doc/CSG/CSG union.svg b/doc/CSG/CSG union.svg
new file mode 100644 (file)
index 0000000..f1eedec
--- /dev/null
@@ -0,0 +1,38 @@
+<svg viewBox="0 0 620 170" width="620" height="170" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="u-glow"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  </defs>
+  <rect width="620" height="170" fill="#061018"/>
+
+  <!-- ── Input ── -->
+  <text x="110" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Input</text>
+  <rect x="50" y="35" width="80" height="80" rx="2" fill="rgba(57,255,20,0.2)" stroke="#39FF14" stroke-width="2"/>
+  <text x="90" y="80" fill="#39FF14" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">A</text>
+  <circle cx="130" cy="75" r="42" fill="rgba(255,102,0,0.2)" stroke="#FF6600" stroke-width="2"/>
+  <text x="148" y="80" fill="#FF6600" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">B</text>
+
+  <!-- ── Operator ── -->
+  <text x="250" y="70" fill="#40b0d0" font-size="22" font-family="monospace" text-anchor="middle" filter="url(#u-glow)">+</text>
+  <text x="250" y="92" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">union</text>
+
+  <!-- ── Arrow ── -->
+  <line x1="280" y1="78" x2="330" y2="78" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <polygon points="330,73 340,78 330,83" fill="#40b0d0"/>
+
+  <!-- ── Result ── -->
+  <text x="440" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Result: A + B</text>
+  <!-- Merged outer boundary: cube left + top + circle right + cube bottom -->
+  <path d="M370,35 L370,115 L450,115 L450,103 A42,42 0 0,0 450,47 L450,35 Z"
+        fill="rgba(57,255,20,0.12)" stroke="#39FF14" stroke-width="1.5"/>
+  <path d="M450,47 A42,42 0 0,1 450,103"
+        fill="rgba(255,102,0,0.12)" stroke="#FF6600" stroke-width="1.5"/>
+  <!-- Interior seam removed indicator -->
+  <line x1="450" y1="47" x2="450" y2="103" stroke="#40b0d0" stroke-width="1" stroke-dasharray="3 2" opacity="0.5"/>
+  <text x="458" y="78" fill="#40b0d0" font-size="7" font-family="monospace" opacity="0.7">removed</text>
+
+  <!-- ── Descriptions ── -->
+  <text x="110" y="142" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Keeps all geometry</text>
+  <text x="110" y="155" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">from both shapes</text>
+  <text x="440" y="142" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Single combined volume</text>
+  <text x="440" y="155" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">interior faces removed</text>
+</svg>
diff --git a/doc/CSG/Polygon clipping.svg b/doc/CSG/Polygon clipping.svg
new file mode 100644 (file)
index 0000000..41de628
--- /dev/null
@@ -0,0 +1,39 @@
+<svg viewBox="0 0 620 190" width="620" height="190" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="c-glow"><feGaussianBlur stdDeviation="1.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  </defs>
+  <rect width="620" height="190" fill="#061018"/>
+
+  <!-- ── Original polygon crossing a plane ── -->
+  <text x="120" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Polygon crosses plane</text>
+  <polygon points="60,50 180,50 120,140" fill="rgba(32,112,192,0.15)" stroke="#2070c0" stroke-width="1.5"/>
+  <line x1="30" y1="70" x2="210" y2="110" stroke="#40b0d0" stroke-width="2" filter="url(#c-glow)"/>
+  <text x="38" y="64" fill="#40b0d0" font-size="9" font-family="monospace">plane</text>
+  <circle cx="81" cy="81" r="3.5" fill="#40b0d0"/>
+  <circle cx="149" cy="96" r="3.5" fill="#40b0d0"/>
+
+  <!-- ── Arrow ── -->
+  <text x="268" y="85" fill="#40b0d0" font-size="18" font-family="monospace" text-anchor="middle" filter="url(#c-glow)">→</text>
+  <text x="268" y="105" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle">split</text>
+
+  <!-- ── Split result ── -->
+  <text x="460" y="20" fill="#ccc" font-size="11" font-family="monospace" text-anchor="middle">Split into fragments</text>
+  <line x1="370" y1="70" x2="550" y2="110" stroke="#40b0d0" stroke-width="0.7" opacity="0.25" stroke-dasharray="4 3"/>
+
+  <!-- Front fragment (above plane) — trapezoid -->
+  <polygon points="400,50 520,50 489,96 421,81" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="458" y="68" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">front</text>
+
+  <!-- Back fragment (below plane) — triangle -->
+  <polygon points="421,81 489,96 460,140" fill="rgba(208,64,64,0.15)" stroke="#d04040" stroke-width="1.5"/>
+  <text x="457" y="115" fill="#d04040" font-size="9" font-family="monospace" text-anchor="middle">back</text>
+
+  <!-- New edge at split -->
+  <line x1="421" y1="81" x2="489" y2="96" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="4 2"/>
+  <circle cx="421" cy="81" r="3.5" fill="#40b0d0"/>
+  <circle cx="489" cy="96" r="3.5" fill="#40b0d0"/>
+  <text x="470" y="78" fill="#40b0d0" font-size="7" font-family="monospace" text-anchor="middle" opacity="0.8">new edge</text>
+
+  <!-- ── Explanation ── -->
+  <text x="310" y="172" fill="#ccc" font-size="9" font-family="monospace" text-anchor="middle">Spanning polygons are split; each fragment goes to its respective subtree</text>
+</svg>
diff --git a/doc/CSG/index.org b/doc/CSG/index.org
new file mode 100644 (file)
index 0000000..ebdbad6
--- /dev/null
@@ -0,0 +1,284 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Constructive Solid Geometry - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* What is CSG?
+:PROPERTIES:
+:CUSTOM_ID: what-is-csg
+:ID:       a1b2c3d4-e5f6-7890-abcd-ef1234567890
+:END:
+
+*Constructive Solid Geometry* (CSG) is a modeling technique that builds
+complex 3D shapes by combining simpler primitives using boolean
+operations. Instead of manually creating every vertex and face, you
+define shapes as the result of operations like "merge these two cubes"
+or "carve a hole using this sphere."
+
+CSG is particularly powerful for:
+- *Procedural modeling* — generate complex geometry algorithmically
+- *CAD/CAM applications* — define parts as combinations of primitives
+- *Game development* — create architectural elements, holes, cavities
+- *Rapid prototyping* — iterate on designs by adjusting operations
+
+The three fundamental CSG operations are:
+
+| Operation   | Symbol | Result                                    |
+|-------------+--------+-------------------------------------------|
+| Subtract    | A - B  | A with B carved out (holes, cavities)     |
+| Union       | A + B  | Combined volume (both shapes merged)      |
+| Intersect   | A ∩ B  | Volume where both overlap                 |
+
+See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#csg-demo][CSG demo]] for an interactive visualization.
+
+* The Three Operations
+:PROPERTIES:
+:CUSTOM_ID: the-three-operations
+:END:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#csg-demo][file:CSG%20demo.png]]
+
+The screenshot above shows all three operations displayed left to right:
+subtract (green cube with spherical cavity), union (merged green and
+orange shapes), and intersect (only the overlapping region in blue).
+
+The diagrams below use the same green cube (A) and orange sphere (B) as
+the screenshot above. Each operation transforms these inputs differently,
+producing the results shown from left to right in the image.
+
+** Subtract (A - B)
+:PROPERTIES:
+:CUSTOM_ID: subtract-operation
+:END:
+
+#+INCLUDE: "CSG operations.svg" export html
+
+*Subtract* removes the orange sphere (B) from the green cube (A), carving
+out a cavity. The diagram shows B acting as a "cutter" — where it overlaps
+A, a hole is created. Interior faces *are preserved* and become visible,
+allowing you to see inside the carved-out space (shown as the orange dashed
+curve in the result).
+
+This matches the leftmost shape in the screenshot: a green cube with a
+visible spherical hollow inside, showing the interior surfaces created by
+the subtraction.
+
+This operation is ideal for creating:
+- Holes and tunnels
+- Carved-out spaces
+- Hollow objects
+
+#+BEGIN_SRC java
+SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE);
+
+cube.subtract(sphere);  // cube now has a spherical cavity
+#+END_SRC
+
+** Union (A + B)
+:PROPERTIES:
+:CUSTOM_ID: union-operation
+:END:
+
+#+INCLUDE: "CSG union.svg" export html
+
+*Union* merges the green cube (A) and orange sphere (B) into one continuous
+volume. The diagram shows both shapes combining — the interior seam (where
+they overlap) is removed, creating a single solid surface with no internal
+boundaries (indicated by the dashed blue line labeled "removed").
+
+This corresponds to the center shape in the screenshot: both green and
+orange colors present but seamlessly joined, forming one unified object.
+
+#+BEGIN_SRC java
+SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE);
+
+cube.union(sphere);  // cube now contains the merged result
+#+END_SRC
+
+** Intersect (A ∩ B)
+:PROPERTIES:
+:CUSTOM_ID: intersect-operation
+:END:
+
+#+INCLUDE: "CSG intersect.svg" export html
+
+*Intersect* keeps only the volume where the green cube (A) and orange
+sphere (B) overlap — the region that is inside *both* shapes
+simultaneously. The diagram shows this as the blue-shaded area: the
+portion of the sphere that fits within the cube boundaries. Everything
+else is discarded.
+
+This is the rightmost shape in the screenshot: only the overlapping
+portion remains, showing which parts of space were occupied by both the
+cube and sphere at the same time.
+
+This operation is useful for:
+- Creating shapes constrained by multiple boundaries
+- Finding collision regions
+- Trimming geometry to fit within bounds
+
+#+BEGIN_SRC java
+SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 60, 8, Color.ORANGE);
+
+cube.intersect(sphere);  // only the overlapping region remains
+#+END_SRC
+
+* BSP Tree Algorithm
+:PROPERTIES:
+:CUSTOM_ID: bsp-tree-algorithm
+:END:
+
+CSG boolean operations are implemented using *Binary Space Partitioning*
+(BSP) trees. A BSP tree recursively divides 3D space using planes,
+creating a hierarchical structure that enables efficient polygon clipping
+and spatial queries.
+
+** BSP Tree Structure
+:PROPERTIES:
+:CUSTOM_ID: bsp-tree-structure
+:END:
+
+#+INCLUDE: "BSP tree.svg" export html
+
+Each BSP node contains:
+- A *partitioning plane* that divides space into two half-spaces
+- *Polygons* that lie exactly on this plane (coplanar)
+- *Front* subtree — polygons on the same side as the plane's normal
+- *Back* subtree — polygons on the opposite side
+
+** Key BSP Operations
+:PROPERTIES:
+:CUSTOM_ID: key-bsp-operations
+:END:
+
+The BSP tree provides three core operations that enable CSG:
+
+| Operation      | Description                                      |
+|----------------+--------------------------------------------------|
+| =invert()=     | Flip all normals, swap front/back children       |
+| =clipTo(tree)= | Remove polygons inside the other tree's solid    |
+| =addPolygons()= | Insert new polygons, splitting at planes         |
+
+*Invert* is fundamental to CSG. By flipping inside/outside, we can
+transform subtraction and intersection into variations of clipping:
+
+- **Subtract** = invert A, clip against B, add B's clipped parts, invert back
+- **Intersect** = invert A, clip B against A, invert B, clip A against B, combine, invert A back
+
+** Polygon Clipping
+:PROPERTIES:
+:CUSTOM_ID: polygon-clipping
+:END:
+
+When a polygon crosses a partitioning plane, it's *split* into two
+fragments:
+
+#+INCLUDE: "Polygon clipping.svg" export html
+
+This recursive splitting ensures that all polygons are cleanly classified
+as entirely in front, entirely behind, or exactly on a plane — never
+"spanning" across.
+
+* Using CSG in Aukio 3D
+:PROPERTIES:
+:CUSTOM_ID: using-csg-in-aukio-3d
+:END:
+
+CSG operations are methods on [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]]. They modify the
+shape *in-place* — the result replaces the original geometry.
+
+** Basic Usage
+:PROPERTIES:
+:CUSTOM_ID: basic-usage
+:END:
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.*;
+
+// Create two shapes
+SolidPolygonCube cube = new SolidPolygonCube(Point3D.origin(), 100, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(Point3D.origin(), 70, 12, Color.ORANGE);
+
+// Perform CSG operations (in-place modification)
+cube.subtract(sphere);   // Cube with spherical cavity
+// or
+cube.union(sphere);      // Merged shape
+// or
+cube.intersect(sphere);  // Only overlapping region
+
+// Add to scene
+shapes.addShape(cube.setBackfaceCulling(true));
+#+END_SRC
+
+** Child Handling Behavior
+:PROPERTIES:
+:CUSTOM_ID: child-handling
+:END:
+
+CSG operations only affect *SolidPolygon* geometry. Other children are
+preserved as objects:
+
+| Child Type            | Union            | Subtract         | Intersect        |
+|-----------------------+------------------+------------------+------------------|
+| SolidPolygon (this)   | Replaced with result | Replaced with result | Replaced with result |
+| SolidPolygon (other)  | Merged into result | Discarded (cutter) | Discarded        |
+| Line, TextCanvas (this) | Preserved      | Preserved        | Preserved        |
+| Line, TextCanvas (other) | Merged into this shape | Discarded | Discarded     |
+| Nested composite      | Preserved as object — but see below | same | same |
+
+*Nested composites are not CSG-safe.* Polygon extraction recurses into
+them, so their SolidPolygons are included in the BSP result — while the
+nested composite object itself is also preserved, duplicating that
+geometry in the render. Apply CSG to flat composites, or extract the
+nested polygons first.
+
+This allows you to attach labels, decorations, or wireframe overlays to
+shapes without them being affected by CSG operations (for union, the
+other shape's decorations are copied over too).
+
+** Important Notes
+:PROPERTIES:
+:CUSTOM_ID: important-notes
+:END:
+
+1. *Shapes are modified in-place*. The original geometry is replaced.
+   Clone shapes beforehand if you need to preserve the originals.
+
+2. *CSG works on SolidPolygon children only*. TexturedTriangle and other
+   shape types are not processed.
+
+3. *Result quality depends on mesh density*. Low-polygon inputs may
+   produce visible artifacts at intersection boundaries. Use higher
+   subdivision counts for smoother results.
+
+4. *Backface culling is recommended*. CSG results often have internal
+   faces from the cutting operation. Enable culling to hide backfaces:
+   =shape.setBackfaceCulling(true)=
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                    | Purpose                                              |
+|--------------------------+------------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/BspTree.html][BspTree]]                  | BSP tree for spatial partitioning and CSG operations |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Plane]]                    | Partitioning plane used by BSP nodes                 |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]]             | Polygon shape processed by CSG                       |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]]   | Base class with union/subtract/intersect methods     |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.html][SolidPolygonMesh]]         | Custom polygon mesh for arbitrary geometry           |
+| SolidPolygon* primitives | See [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery]] for all available shapes           |
diff --git a/doc/Coordinate system.svg b/doc/Coordinate system.svg
new file mode 100644 (file)
index 0000000..4497bf3
--- /dev/null
@@ -0,0 +1,18 @@
+<svg viewBox="0 0 640 520" width="640" height="520" xmlns="http://www.w3.org/2000/svg">
+  <rect width="640" height="520" fill="#061018"/>
+  <circle cx="280" cy="260" r="10" fill="rgba(80,96,192,0.1)" stroke="rgba(80,96,192,0.3)" stroke-width="2"/>
+  <line x1="280" y1="260" x2="560" y2="260" stroke="#d04040" stroke-width="5"/>
+  <polygon points="560,260 540,250 540,270" fill="#d04040"/>
+  <text x="568" y="268" fill="#d04040" font-size="28" font-weight="700" font-family="monospace">X</text>
+  <text x="400" y="304" fill="#bbb" font-size="18" font-family="monospace">right (+) / left (-)</text>
+  <line x1="280" y1="260" x2="280" y2="480" stroke="#30a050" stroke-width="5"/>
+  <polygon points="280,480 270,460 290,460" fill="#30a050"/>
+  <text x="292" y="504" fill="#30a050" font-size="28" font-weight="700" font-family="monospace">Y</text>
+  <text x="292" y="456" fill="#bbb" font-size="18" font-family="monospace">down (+) / up (-)</text>
+  <line x1="280" y1="260" x2="120" y2="140" stroke="#2070c0" stroke-width="5"/>
+  <polygon points="120,140 140,144 132,164" fill="#2070c0"/>
+  <text x="84" y="124" fill="#2070c0" font-size="28" font-weight="700" font-family="monospace">Z</text>
+  <text x="120" y="112" fill="#bbb" font-size="18" font-family="monospace">away (+) / towards (-)</text>
+  <text x="300" y="204" fill="#aaa" font-size="22" font-weight="600" font-family="monospace">Origin</text>
+  <text x="294" y="230" fill="#bbb" font-size="18" font-family="monospace">(0, 0, 0)</text>
+</svg>
\ No newline at end of file
diff --git a/doc/Depth buffer/index.org b/doc/Depth buffer/index.org
new file mode 100644 (file)
index 0000000..2f2c75d
--- /dev/null
@@ -0,0 +1,130 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Depth Buffer - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-depth-buffer][<- Back to index]]
+
+* Per-pixel visibility
+:PROPERTIES:
+:CUSTOM_ID: per-pixel
+:END:
+
+The engine resolves visibility with a depth buffer, not paint order.
+Every rasterized triangle carries a per-pixel depth quantity =zw =
+1/z= (camera-space), interpolated linearly across each span — =1/z= is
+affine in screen space, so it rides the same edge interpolators as the
+texture gradients. A fragment wins a pixel only where
+
+#+BEGIN_EXAMPLE
+zw > stored - margin * zw^2        (margin = 0 by default)
+#+END_EXAMPLE
+
+Default margin 0 is *strict depth*: the nearer fragment always wins,
+regardless of paint order, so overlapping depth ranges — a floor tile
+extending under furniture, a wall seen through a doorway — come out
+correct per pixel. A nonzero margin (= RenderingContext.DEPTH_MARGIN_DZ =,
+=-Daukio.zbuffer.margin=, world units) re-opens a tolerance window
+*behind* the stored depth for near-coplanar pairs; any nonzero window
+re-imports per-triangle sort errors into per-pixel occlusion, which is
+why the default is strict.
+
+The depth buffer is allocated once per frame context
+(=RenderingContext.depth=, float per pixel) and cleared per tile
+together with the pixel buffer.
+
+* Two passes
+:PROPERTIES:
+:CUSTOM_ID: two-passes
+:END:
+
+=RenderAggregator.paintSorted= paints the sorted queue in two passes:
+
+1. *Opaque pass* — opaque-class triangles, iterated front-to-back (the
+   queue is back-to-front, so reversed), depth test + depth write.
+   Front-to-back order is a pure performance hint: hidden fragments
+   die on the depth test *before* the texture fetch (early-z).
+2. *Alpha pass* — alpha-class triangles (translucent solid polygons,
+   alpha-carrying textures, SDF text), iterated back-to-front in queue
+   order, depth test but *no depth write*. Translucency never
+   occludes, and overlapping translucent surfaces keep painter-coherent
+   mutual order.
+
+A shape's class comes from its paint color or texture: solid polygons
+with =alpha = 255= are opaque, anything translucent is alpha-class;
+textured triangles are alpha-class when the texture has alpha or is an
+SDF mask.
+
+* Which shapes carry depth
+:PROPERTIES:
+:CUSTOM_ID: shapes
+:END:
+
+- =TexturedTriangle= — opaque or alpha class by texture.
+- =SolidPolygon= — depth-tested since 2026-09-17; opaque when its
+  (possibly shaded) color is fully opaque, translucent otherwise.
+  Near-plane-clipped quads fan-triangulate with depth like any other
+  triangles.
+- =LightmappedTriangle= — a textured triangle, so the same rules.
+- =Line=, =Billboard=, =GlowingPoint= — no depth by design: they are
+  2D overlays (wireframes, markers, sprites) and always paint on top,
+  in the alpha pass.
+
+Because every occluder writes depth, scene code no longer needs any
+ordering structure: composites just fan-triangulate their polygons.
+(=LightmappedCompositeShape= exists only to wrap polygons as lightmap
+carriers for the GI system, not to order them.)
+
+* Hi-Z occlusion pyramid
+:PROPERTIES:
+:CUSTOM_ID: hi-z
+:END:
+
+After each successful paint, =ViewPanel= builds a Hi-Z pyramid from
+the depth buffer (gui/HiZPyramid): 8-pixel tiles pooled upward,
+each tile storing the *minimum* =zw= (farthest written depth —
+max-pooling would store the nearest occluder and wrongly cull geometry
+visible between near gaps). Next frame, =TriangleMeshBlock= projects
+its world AABB's 8 corners and, when the nearest corner is still
+behind the pyramid's stored depth, skips the whole block before any
+per-triangle work.
+
+The test is conservative by construction (min-pooling plus sky pixels
+at =-inf=), so it never culls visible geometry; wrong culls under
+camera motion self-heal in one frame. Knobs: =-Daukio.hiz.margin=0.02=,
+kill switch =-Daukio.hiz=false=. Headless snapshots never build the
+pyramid, so golden renders are structurally unaffected. Stereo skips
+the test (the pyramid is mono).
+
+* Determinism and depth dumps
+:PROPERTIES:
+:CUSTOM_ID: determinism
+:END:
+
+The renderer is bit-deterministic: same scene and camera give
+bit-identical pixels across runs, which is what the golden-image
+regression tests compare. =Snapshot= (the headless toolkit) supports
+=-Daukio.zbuffer.dumpDepth=path.png= to write a grayscale depth map
+alongside the color image — useful when hunting depth-window bugs
+(bisect those with =-Daukio.zbuffer.margin=0=).
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                | Role                                                     |
+|----------------------+----------------------------------------------------------|
+| =RenderingContext=   | =depth= buffer, =depthPass=, =DEPTH_MARGIN_DZ= constant  |
+| =RenderAggregator=   | =paintSorted= two-pass driver, queue sort                |
+| =TexturedTriangle=   | Z span writers (perspective and affine)                  |
+| =SolidPolygon=       | flat-color Z span writer, two-pass classification        |
+| =HiZPyramid=         | temporal whole-block occlusion culling                   |
+| =ViewPanel=          | per-tile depth clear, pyramid rebuild after paint        |
+
+[[file:../index.html#outline-container-depth-buffer][Back to main documentation]]
diff --git a/doc/Developer tools/Developer tools.png b/doc/Developer tools/Developer tools.png
new file mode 100644 (file)
index 0000000..825b0de
Binary files /dev/null and b/doc/Developer tools/Developer tools.png differ
diff --git a/doc/Developer tools/Render alternative segments.png b/doc/Developer tools/Render alternative segments.png
new file mode 100644 (file)
index 0000000..e2bd569
Binary files /dev/null and b/doc/Developer tools/Render alternative segments.png differ
diff --git a/doc/Developer tools/Render polygon borders.png b/doc/Developer tools/Render polygon borders.png
new file mode 100644 (file)
index 0000000..5ec2182
Binary files /dev/null and b/doc/Developer tools/Render polygon borders.png differ
diff --git a/doc/Developer tools/Show segment boundaries.png b/doc/Developer tools/Show segment boundaries.png
new file mode 100644 (file)
index 0000000..01a1978
Binary files /dev/null and b/doc/Developer tools/Show segment boundaries.png differ
diff --git a/doc/Developer tools/Thread timeline.png b/doc/Developer tools/Thread timeline.png
new file mode 100644 (file)
index 0000000..dd1d378
Binary files /dev/null and b/doc/Developer tools/Thread timeline.png differ
diff --git a/doc/Edge.svg b/doc/Edge.svg
new file mode 100644 (file)
index 0000000..e9af1cf
--- /dev/null
@@ -0,0 +1,12 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <rect width="640" height="480" fill="#061018"/>
+  <polygon points="320,100 160,380 480,380" fill="rgba(100,100,200,0.04)" stroke="rgba(100,100,200,0.2)" stroke-width="2"/>
+  <line x1="320" y1="100" x2="480" y2="380" stroke="#5060c0" stroke-width="6" stroke-linecap="round"/>
+  <circle cx="320" cy="100" r="10" fill="#5060c0"/>
+  <circle cx="160" cy="380" r="8" fill="rgba(80,96,192,0.5)"/>
+  <circle cx="480" cy="380" r="10" fill="#5060c0"/>
+  <text x="300" y="80" fill="#aaa" font-size="20" font-family="monospace">V₁</text>
+  <text x="492" y="388" fill="#aaa" font-size="20" font-family="monospace">V₂</text>
+  <text x="120" y="400" fill="#bbb" font-size="20" font-family="monospace">V₃</text>
+  <text x="420" y="220" fill="#5060c0" font-size="24" font-weight="700" font-family="monospace" transform="rotate(30 420 220)">edge</text>
+</svg>
diff --git a/doc/Example.png b/doc/Example.png
new file mode 100644 (file)
index 0000000..7094240
Binary files /dev/null and b/doc/Example.png differ
diff --git a/doc/Face triangle.svg b/doc/Face triangle.svg
new file mode 100644 (file)
index 0000000..509c841
--- /dev/null
@@ -0,0 +1,14 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <rect width="640" height="480" fill="#061018"/>
+  <polygon points="320,80 120,400 520,400" fill="rgba(200,80,140,0.15)" stroke="#c05088" stroke-width="3"/>
+  <line x1="200" y1="280" x2="440" y2="280" stroke="rgba(200,80,140,0.1)" stroke-width="1"/>
+  <line x1="240" y1="320" x2="400" y2="320" stroke="rgba(200,80,140,0.08)" stroke-width="1"/>
+  <line x1="164" y1="360" x2="476" y2="360" stroke="rgba(200,80,140,0.06)" stroke-width="1"/>
+  <circle cx="320" cy="80" r="8" fill="#c05088"/>
+  <circle cx="120" cy="400" r="8" fill="#c05088"/>
+  <circle cx="520" cy="400" r="8" fill="#c05088"/>
+  <text x="296" y="60" fill="#c05088" font-size="20" font-weight="700" font-family="monospace">V₁</text>
+  <text x="76" y="420" fill="#c05088" font-size="20" font-weight="700" font-family="monospace">V₂</text>
+  <text x="532" y="420" fill="#c05088" font-size="20" font-weight="700" font-family="monospace">V₃</text>
+  <text x="264" y="300" fill="rgba(192,80,136,0.5)" font-size="28" font-weight="700" font-family="monospace">FACE</text>
+</svg>
diff --git a/doc/Frustum culling/Frustum diagram.svg b/doc/Frustum culling/Frustum diagram.svg
new file mode 100644 (file)
index 0000000..b59d4a8
--- /dev/null
@@ -0,0 +1,58 @@
+<svg viewBox="0 0 620 300" width="620" height="300" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="f-glow"><feGaussianBlur stdDeviation="2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  </defs>
+  <rect width="620" height="300" fill="#061018"/>
+
+  <!-- Z axis -->
+  <line x1="60" y1="150" x2="590" y2="150" stroke="rgba(32,112,192,0.2)" stroke-width="1" stroke-dasharray="6 3"/>
+  <text x="570" y="143" fill="#2070c0" font-size="11" font-weight="700" font-family="monospace">+Z</text>
+  <text x="530" y="163" fill="#999" font-size="8" font-family="monospace">(view direction)</text>
+
+  <!-- Camera -->
+  <circle cx="60" cy="150" r="7" fill="rgba(32,112,192,0.3)" stroke="#2070c0" stroke-width="2" filter="url(#f-glow)"/>
+  <text x="74" y="154" fill="#2070c0" font-size="12" font-weight="700" font-family="monospace">Camera</text>
+
+  <!-- Frustum edges: camera → near corners -->
+  <line x1="60" y1="150" x2="170" y2="105" stroke="rgba(48,160,80,0.3)" stroke-width="1"/>
+  <line x1="60" y1="150" x2="170" y2="195" stroke="rgba(48,160,80,0.3)" stroke-width="1"/>
+  <!-- Frustum edges: near → far corners -->
+  <line x1="170" y1="105" x2="440" y2="40" stroke="rgba(48,160,80,0.25)" stroke-width="1"/>
+  <line x1="170" y1="195" x2="440" y2="260" stroke="rgba(48,160,80,0.25)" stroke-width="1"/>
+  <!-- Extended rays behind far (faint dashed) -->
+  <line x1="440" y1="40" x2="520" y2="10" stroke="rgba(48,160,80,0.1)" stroke-width="1" stroke-dasharray="3 3"/>
+  <line x1="440" y1="260" x2="520" y2="290" stroke="rgba(48,160,80,0.1)" stroke-width="1" stroke-dasharray="3 3"/>
+
+  <!-- Near plane -->
+  <rect x="168" y="105" width="4" height="90" rx="1" fill="rgba(48,160,80,0.25)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="155" y="96" fill="#30a050" font-size="10" font-family="monospace" font-weight="700">Near</text>
+
+  <!-- Far plane -->
+  <rect x="438" y="40" width="4" height="220" rx="1" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="425" y="32" fill="#30a050" font-size="10" font-family="monospace" font-weight="700">Far</text>
+
+  <!-- Frustum fill (the visible volume) -->
+  <polygon points="170,105 170,195 440,260 440,40" fill="rgba(48,160,80,0.06)" stroke="none"/>
+
+  <!-- Visible region label -->
+  <text x="290" y="145" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle" opacity="0.6">visible region</text>
+
+  <!-- Plane labels along frustum edges -->
+  <text x="295" y="62" fill="#999" font-size="8" font-family="monospace" text-anchor="middle">Top plane</text>
+  <text x="295" y="242" fill="#999" font-size="8" font-family="monospace" text-anchor="middle">Bottom plane</text>
+
+  <!-- Object inside frustum (rendered) -->
+  <rect x="270" y="130" width="28" height="28" rx="2" fill="rgba(48,160,80,0.2)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="276" y="150" fill="#30a050" font-size="14" font-family="monospace">✓</text>
+  <text x="258" y="172" fill="#30a050" font-size="8" font-family="monospace">rendered</text>
+
+  <!-- Object outside frustum (culled — above) -->
+  <rect x="480" y="18" width="24" height="24" rx="2" fill="rgba(208,64,64,0.12)" stroke="rgba(208,64,64,0.5)" stroke-width="1.5" stroke-dasharray="3 2"/>
+  <text x="485" y="36" fill="rgba(208,64,64,0.8)" font-size="13" font-family="monospace">✗</text>
+  <text x="470" y="52" fill="rgba(208,64,64,0.6)" font-size="8" font-family="monospace">culled</text>
+
+  <!-- Object outside frustum (culled — below) -->
+  <rect x="310" y="266" width="24" height="24" rx="2" fill="rgba(208,64,64,0.12)" stroke="rgba(208,64,64,0.5)" stroke-width="1.5" stroke-dasharray="3 2"/>
+  <text x="315" y="284" fill="rgba(208,64,64,0.8)" font-size="13" font-family="monospace">✗</text>
+  <text x="300" y="300" fill="rgba(208,64,64,0.6)" font-size="8" font-family="monospace">culled</text>
+</svg>
diff --git a/doc/Frustum culling/P-vertex AABB.svg b/doc/Frustum culling/P-vertex AABB.svg
new file mode 100644 (file)
index 0000000..a3acfb8
--- /dev/null
@@ -0,0 +1,38 @@
+<svg viewBox="0 0 520 200" width="520" height="200" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="p-glow"><feGaussianBlur stdDeviation="1.5" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  </defs>
+  <rect width="520" height="200" fill="#061018"/>
+
+  <!-- Explanation text -->
+  <text x="30" y="22" fill="#aaa" font-size="10" font-family="monospace">P-vertex: corner most aligned with plane normal</text>
+  <text x="30" y="36" fill="#bbb" font-size="9" font-family="monospace">If P is behind the plane → entire AABB is outside</text>
+
+  <!-- Frustum plane (diagonal line) -->
+  <line x1="50" y1="170" x2="430" y2="55" stroke="#b09020" stroke-width="2" filter="url(#p-glow)"/>
+  <text x="432" y="52" fill="#b09020" font-size="10" font-weight="700" font-family="monospace">Plane</text>
+
+  <!-- "inside" region label -->
+  <text x="100" y="80" fill="rgba(48,160,80,0.4)" font-size="10" font-family="monospace">inside frustum</text>
+  <!-- "outside" region label -->
+  <text x="310" y="170" fill="rgba(208,64,64,0.4)" font-size="10" font-family="monospace">outside frustum</text>
+
+  <!-- Normal vector arrow -->
+  <line x1="270" y1="100" x2="230" y2="78" stroke="#b09020" stroke-width="1.5"/>
+  <polygon points="230,78 237,76 236,83" fill="#b09020"/>
+  <text x="222" y="72" fill="#b09020" font-size="9" font-family="monospace" font-weight="700">N</text>
+
+  <!-- AABB inside frustum (fully visible) -->
+  <rect x="80" y="100" width="60" height="50" rx="2" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="97" y="130" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">inside</text>
+  <!-- P-vertex for inside box (top-right corner, closest to plane) -->
+  <circle cx="140" cy="100" r="3.5" fill="#30a050"/>
+  <text x="145" y="97" fill="#30a050" font-size="7" font-family="monospace">P</text>
+
+  <!-- AABB outside frustum (fully culled) -->
+  <rect x="340" y="110" width="60" height="50" rx="2" fill="rgba(208,64,64,0.12)" stroke="rgba(208,64,64,0.5)" stroke-width="1.5" stroke-dasharray="3 2"/>
+  <text x="356" y="140" fill="rgba(208,64,64,0.7)" font-size="9" font-family="monospace" text-anchor="middle">outside</text>
+  <!-- P-vertex for outside box (top-left corner, closest to plane) -->
+  <circle cx="340" cy="110" r="3.5" fill="rgba(208,64,64,0.8)"/>
+  <text x="327" y="107" fill="rgba(208,64,64,0.8)" font-size="7" font-family="monospace">P</text>
+</svg>
diff --git a/doc/Frustum culling/index.org b/doc/Frustum culling/index.org
new file mode 100644 (file)
index 0000000..9c4a941
--- /dev/null
@@ -0,0 +1,177 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Frustum & View Frustum Culling - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* Frustum & View Frustum Culling
+:PROPERTIES:
+:CUSTOM_ID: frustum-view-frustum-culling
+:END:
+
+#+INCLUDE: "Frustum diagram.svg" export html
+
+The *view frustum* is a truncated pyramid-shaped volume that represents
+everything the camera can see. Objects completely outside this volume are
+skipped during rendering — a powerful optimization called *frustum culling*.
+
+** The Six Frustum Planes
+:PROPERTIES:
+:CUSTOM_ID: frustum-planes
+:END:
+
+The frustum is defined by six clipping planes:
+
+| Plane   | Purpose                                    |
+|---------+--------------------------------------------|
+| Left    | Left edge of viewport                      |
+| Right   | Right edge of viewport                     |
+| Top     | Top edge of viewport (smaller Y in Y-down) |
+| Bottom  | Bottom edge of viewport (larger Y)         |
+| Near    | Closest visible distance from camera       |
+| Far     | Farthest visible distance from camera      |
+
+Each plane divides 3D space into "inside" (visible) and "outside"
+(culled). An object must pass all six plane tests to be considered
+potentially visible.
+
+** Frustum Culling vs Backface Culling
+:PROPERTIES:
+:CUSTOM_ID: frustum-vs-backface-culling
+:END:
+
+These are complementary optimizations at different levels:
+
+| Optimization    | Level        | What it skips                    |
+|-----------------+--------------+----------------------------------|
+| Frustum culling | Object level | Entire composite shapes + children |
+| Backface culling | Polygon level | Individual triangles facing away |
+
+*Frustum culling* happens first during the transform phase — entire
+object trees are skipped with a single bounding box test. *Backface
+culling* happens later during rasterization — individual triangles
+are checked before being drawn.
+
+For best performance, use both: organize your scene with composite
+shapes for effective frustum culling, and enable backface culling on
+closed meshes.
+
+* How Frustum Culling Works in Aukio 3D
+:PROPERTIES:
+:CUSTOM_ID: frustum-culling-implementation
+:END:
+
+Frustum culling is applied automatically to all [[../index.org#mesh][composite shapes]]
+during Phase 1 (transform) of the [[../Rendering loop/][rendering loop]]:
+
+1. *Update frustum*: Compute 6 planes from camera FOV and viewport size
+2. *For each composite shape*:
+   - Get its [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Box.html][Axis-Aligned Bounding Box (AABB)]]
+   - Transform all 8 corners to view space
+   - Test against frustum using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Frustum.html#intersectsAABB][intersectsAABB()]]
+   - If outside: skip the entire composite and all children
+   - If inside: continue transforming children
+
+(The root composite itself is never tested — it is always rendered.)
+
+** The AABB Intersection Algorithm
+:PROPERTIES:
+:CUSTOM_ID: aabb-intersection-algorithm
+:END:
+
+The intersection test uses an optimized "P-vertex" approach:
+
+#+INCLUDE: "P-vertex AABB.svg" export html
+
+For each plane, instead of testing all 8 corners of the bounding box,
+we test only the *P-vertex* — the corner most aligned with the plane
+normal. If this "best" corner is behind the plane, the entire box must
+be outside the frustum.
+
+- Plane normal points *into* the frustum (toward visible region)
+- P-vertex: select corner based on normal direction
+  - If normal.x > 0 → use maxX (rightmost corner)
+  - If normal.x < 0 → use minX (leftmost corner)
+  - Same logic for Y and Z
+- Test: =dot(normal, P-vertex) < distance= → outside
+
+This reduces from 48 tests (8 corners × 6 planes) to just 6 tests per
+object.
+
+* Performance Benefits
+:PROPERTIES:
+:CUSTOM_ID: frustum-performance
+:END:
+
+Frustum culling can dramatically improve performance for large scenes:
+
+- *High cull % (60-90%)*: Excellent — most objects skipped entirely
+- *Medium cull % (20-60%)*: Moderate benefit
+- *Low cull % (0-20%)*: Limited benefit — most objects visible
+
+A composite shape that is culled skips:
+- Transforming all its children
+- Computing bounding boxes for children
+- All polygon-level operations (backface culling, rasterization)
+
+Open Developer Tools (F12) to see real-time [[../index.org#frustum-culling-statistics][frustum culling statistics]].
+
+* Scene Design for Effective Culling
+:PROPERTIES:
+:CUSTOM_ID: frustum-scene-design
+:END:
+
+Frustum culling works best when you organize your scene into
+well-defined composite shapes:
+
+#+BEGIN_SRC java
+// Good: Each building is a separate composite
+AbstractCompositeShape cityBlock = new AbstractCompositeShape();
+for (Building building : buildings) {
+    AbstractCompositeShape buildingComposite = new AbstractCompositeShape();
+    buildingComposite.addShape(buildingWalls);
+    buildingComposite.addShape(buildingRoof);
+    buildingComposite.addShape(buildingInterior);
+    cityBlock.addShape(buildingComposite);
+}
+
+// Less effective: Everything in one giant composite
+AbstractCompositeShape allObjects = new AbstractCompositeShape();
+allObjects.addShape(building1Walls);
+allObjects.addShape(building1Roof);
+allObjects.addShape(building2Walls);
+// ... hundreds of shapes directly in root
+#+END_SRC
+
+*Best practices:*
+
+- Use composites to group objects that occupy a bounded region of space
+- Keep bounding boxes tight (don't add distant objects to the same composite)
+- Nest composites hierarchically for multi-level culling (city → block → building)
+- Call [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.html#invalidateBounds()][invalidateBounds()]] after moving shapes — the bounding box is
+  recomputed lazily on next use
+
+* Technical Details
+:PROPERTIES:
+:CUSTOM_ID: frustum-technical-details
+:END:
+
+The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Frustum.html][Frustum]] class:
+
+- Computes planes in *view space* (camera at origin, looking along +Z)
+- FOV derived from =projectionScale = width / 3= (≈112° horizontal FOV)
+- Default clip distances: Near = 1.0, Far = 10000.0
+- Planes stored in [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Hesse normal form]]: (normal vector, distance)
+
+The frustum is updated once per render pass in
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.html][ShapeCollection.transformShapesBegin()]], from the camera state and
+the stereo viewport width — in stereo mode each eye gets its own
+frustum, so the update runs twice per frame.
diff --git a/doc/Global illumination/Bounce estimator.svg b/doc/Global illumination/Bounce estimator.svg
new file mode 100644 (file)
index 0000000..0d1972e
--- /dev/null
@@ -0,0 +1,76 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arrowGold" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
+      <polygon points="0,0 8,3 0,6" fill="#b09020"/>
+    </marker>
+    <marker id="arrowCyan" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
+      <polygon points="0,0 8,3 0,6" fill="#40b0d0"/>
+    </marker>
+    <marker id="arrowGreen" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
+      <polygon points="0,0 8,3 0,6" fill="#39FF14"/>
+    </marker>
+  </defs>
+  <rect width="640" height="480" fill="#061018"/>
+
+  <!-- floor -->
+  <line x1="50" y1="390" x2="600" y2="390" stroke="#30a050" stroke-width="3"/>
+  <text x="60" y="410" fill="#30a050" font-size="11" font-family="monospace">surface</text>
+
+  <!-- wall on the right -->
+  <line x1="480" y1="170" x2="480" y2="390" stroke="#30a050" stroke-width="3"/>
+
+  <!-- sample point P + normal -->
+  <circle cx="230" cy="390" r="6" fill="#c05088" filter="url(#glow)"/>
+  <text x="218" y="414" fill="#c05088" font-size="13" font-family="monospace" text-anchor="middle">P</text>
+  <line x1="230" y1="384" x2="230" y2="300" stroke="#b09020" stroke-width="2" marker-end="url(#arrowGold)"/>
+  <text x="240" y="330" fill="#b09020" font-size="11" font-family="monospace">normal</text>
+
+  <!-- cosine hemisphere dome -->
+  <path d="M 140 390 A 90 90 0 0 1 320 390" fill="none" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="6 3"/>
+  <text x="150" y="292" fill="#40b0d0" font-size="10" font-family="monospace">cosine-weighted</text>
+  <text x="150" y="306" fill="#40b0d0" font-size="10" font-family="monospace">hemisphere</text>
+
+  <!-- a few faint sample rays -->
+  <line x1="230" y1="384" x2="160" y2="310" stroke="#40b0d0" stroke-width="1" stroke-dasharray="3 2"/>
+  <line x1="230" y1="384" x2="300" y2="306" stroke="#40b0d0" stroke-width="1" stroke-dasharray="3 2"/>
+
+  <!-- THE bounce ray: P to Q on the wall -->
+  <line x1="230" y1="384" x2="472" y2="288" stroke="#40b0d0" stroke-width="2.5" marker-end="url(#arrowCyan)" filter="url(#glow)"/>
+  <circle cx="476" cy="285" r="6" fill="#40b0d0" filter="url(#glow)"/>
+  <text x="492" y="280" fill="#40b0d0" font-size="13" font-family="monospace">Q</text>
+
+  <!-- lamp -->
+  <circle cx="540" cy="70" r="14" fill="rgba(255,102,0,0.25)" stroke="#FF6600" stroke-width="2" filter="url(#glow)"/>
+  <circle cx="540" cy="70" r="5" fill="#FF6600"/>
+  <text x="540" y="104" fill="#FF8833" font-size="11" font-family="monospace" text-anchor="middle">light</text>
+
+  <!-- shadow ray P -> light (visible) -->
+  <line x1="236" y1="384" x2="528" y2="76" stroke="#39FF14" stroke-width="1.5" stroke-dasharray="8 4" marker-end="url(#arrowGreen)"/>
+  <text x="330" y="230" fill="#39FF14" font-size="10" font-family="monospace" transform="rotate(-52 330 230)">shadow ray: clear</text>
+
+  <!-- occluded shadow ray from a second point -->
+  <circle cx="400" cy="390" r="4" fill="rgba(192,80,136,0.6)"/>
+  <line x1="404" y1="384" x2="452" y2="240" stroke="#FF4444" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <rect x="438" y="196" width="34" height="44" fill="rgba(255,68,68,0.15)" stroke="#FF4444" stroke-width="1.5"/>
+  <text x="455" y="188" fill="#FF4444" font-size="10" font-family="monospace" text-anchor="middle">occluder</text>
+  <text x="438" y="330" fill="#FF4444" font-size="10" font-family="monospace" transform="rotate(-62 438 330)">blocked</text>
+
+  <!-- what happens at Q -->
+  <rect x="360" y="120" width="260" height="58" rx="6" fill="rgba(32,112,192,0.1)" stroke="#2070c0" stroke-width="1.5"/>
+  <text x="372" y="142" fill="#2070c0" font-size="11" font-family="monospace">at Q: direct light (cached shadow bits)</text>
+  <text x="372" y="160" fill="#2070c0" font-size="11" font-family="monospace">     + Q's current indirect estimate</text>
+
+  <!-- P's update -->
+  <rect x="60" y="60" width="280" height="44" rx="6" fill="rgba(48,160,80,0.1)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="72" y="78" fill="#39FF14" font-size="11" font-family="monospace">P's target = (albedo / &#960;) x (direct + indirect)</text>
+  <text x="72" y="94" fill="#30a050" font-size="10" font-family="monospace">blended in with an exponential moving average</text>
+
+  <text x="320" y="462" fill="#aaa" font-size="10" font-family="monospace" text-anchor="middle">one sample per texel per visit: one shadow ray + one bounce ray — bounce light ripples deeper every sweep</text>
+</svg>
diff --git a/doc/Global illumination/GI pipeline.svg b/doc/Global illumination/GI pipeline.svg
new file mode 100644 (file)
index 0000000..0f63023
--- /dev/null
@@ -0,0 +1,55 @@
+<svg viewBox="0 0 620 170" width="620" height="170" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arrowhead" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
+      <polygon points="0,0 8,3 0,6" fill="#40b0d0"/>
+    </marker>
+  </defs>
+  <rect width="620" height="170" fill="#061018"/>
+
+  <!-- pipeline boxes -->
+  <rect x="10" y="45" width="104" height="64" rx="3" fill="rgba(80,96,192,0.15)" stroke="#5060c0" stroke-width="1.5"/>
+  <text x="62" y="66" fill="#5060c0" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">scene snapshot</text>
+  <text x="62" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">triangles + lights</text>
+  <text x="62" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">+ BVH</text>
+
+  <rect x="136" y="45" width="104" height="64" rx="3" fill="rgba(255,102,0,0.15)" stroke="#FF6600" stroke-width="1.5"/>
+  <text x="188" y="66" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">GI workers</text>
+  <text x="188" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">Monte Carlo</text>
+  <text x="188" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">sweeps</text>
+
+  <rect x="262" y="45" width="104" height="64" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="314" y="66" fill="#39FF14" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">lightmaps</text>
+  <text x="314" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">per-texel indirect</text>
+  <text x="314" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">+ shadow bits</text>
+
+  <rect x="388" y="45" width="104" height="64" rx="3" fill="rgba(192,80,136,0.15)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="440" y="60" fill="#c05088" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">composite</text>
+  <text x="440" y="74" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">baseColor x light</text>
+  <text x="440" y="86" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">double-buffered</text>
+  <text x="440" y="98" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">swap</text>
+
+  <rect x="514" y="45" width="96" height="64" rx="3" fill="rgba(64,176,208,0.15)" stroke="#40b0d0" stroke-width="1.5"/>
+  <text x="562" y="66" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">painter</text>
+  <text x="562" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">plain texture</text>
+  <text x="562" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">lookup</text>
+
+  <!-- arrows -->
+  <line x1="114" y1="77" x2="134" y2="77" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="240" y1="77" x2="260" y2="77" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="366" y1="77" x2="386" y2="77" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="492" y1="77" x2="512" y2="77" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+  <!-- feedback arrow: lightmaps feed the next sweep's bounce targets -->
+  <path d="M 314 109 L 314 135 L 188 135 L 188 111" fill="none" stroke="#30a050" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrowhead)"/>
+  <text x="251" y="147" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">bounce reads last sweep's estimate</text>
+
+  <text x="562" y="135" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">zero ray casting</text>
+  <text x="562" y="147" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">on render threads</text>
+</svg>
diff --git a/doc/Global illumination/Global illumination.png b/doc/Global illumination/Global illumination.png
new file mode 100644 (file)
index 0000000..9909193
Binary files /dev/null and b/doc/Global illumination/Global illumination.png differ
diff --git a/doc/Global illumination/Lightmap mapping.svg b/doc/Global illumination/Lightmap mapping.svg
new file mode 100644 (file)
index 0000000..2d82fea
--- /dev/null
@@ -0,0 +1,75 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arrowhead2" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
+      <polygon points="0,0 8,3 0,6" fill="#b09020"/>
+    </marker>
+  </defs>
+  <rect width="640" height="480" fill="#061018"/>
+
+  <!-- bounding square: the full texture; valid region is the lower-left half (u+v <= 1) -->
+  <rect x="140" y="80" width="320" height="320" fill="rgba(255,68,68,0.06)" stroke="#FF4444" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <text x="372" y="110" fill="#FF4444" font-size="10" font-family="monospace" text-anchor="middle">invalid half (u+v &gt; 1)</text>
+  <text x="372" y="126" fill="#FF4444" font-size="9" font-family="monospace" text-anchor="middle">filled from neighbors so sampling</text>
+  <text x="372" y="138" fill="#FF4444" font-size="9" font-family="monospace" text-anchor="middle">near the hypotenuse stays clean</text>
+
+  <!-- the triangle (valid region) -->
+  <polygon points="140,400 460,400 140,80" fill="rgba(48,160,80,0.12)" stroke="#39FF14" stroke-width="2" filter="url(#glow)"/>
+
+  <!-- texel grid inside the triangle (8x8) -->
+  <line x1="180" y1="360" x2="180" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="220" y1="320" x2="220" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="260" y1="280" x2="260" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="300" y1="240" x2="300" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="340" y1="200" x2="340" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="380" y1="160" x2="380" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="420" y1="120" x2="420" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="360" x2="420" y2="360" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="320" x2="380" y2="320" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="280" x2="340" y2="280" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="240" x2="300" y2="240" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="200" x2="260" y2="200" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="160" x2="220" y2="160" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="140" y1="120" x2="180" y2="120" stroke="#1a3a4a" stroke-width="1"/>
+
+  <!-- texel center dots -->
+  <circle cx="160" cy="380" r="2" fill="#c05088"/>
+  <circle cx="200" cy="380" r="2" fill="#c05088"/>
+  <circle cx="240" cy="380" r="2" fill="#c05088"/>
+  <circle cx="160" cy="340" r="2" fill="#c05088"/>
+  <circle cx="200" cy="340" r="2" fill="#c05088"/>
+  <circle cx="160" cy="300" r="2" fill="#c05088"/>
+
+  <!-- one texel highlighted with its world position -->
+  <rect x="200" y="280" width="40" height="40" fill="rgba(255,136,51,0.2)" stroke="#FF8833" stroke-width="1.5"/>
+  <circle cx="220" cy="300" r="3.5" fill="#FF6600" filter="url(#glow)"/>
+  <text x="190" y="348" fill="#FF8833" font-size="10" font-family="monospace">one texel = one surface</text>
+  <text x="190" y="362" fill="#FF8833" font-size="10" font-family="monospace">patch, sampled at center</text>
+
+  <!-- vertices -->
+  <circle cx="140" cy="400" r="6" fill="#c05088"/>
+  <text x="128" y="420" fill="#c05088" font-size="12" font-family="monospace" text-anchor="middle">a (u=0, v=0)</text>
+  <circle cx="460" cy="400" r="6" fill="#c05088"/>
+  <text x="460" y="420" fill="#c05088" font-size="12" font-family="monospace" text-anchor="middle">b (u=1, v=0)</text>
+  <circle cx="140" cy="80" r="6" fill="#c05088"/>
+  <text x="128" y="70" fill="#c05088" font-size="12" font-family="monospace" text-anchor="middle">c (u=0, v=1)</text>
+
+  <!-- edge vectors -->
+  <line x1="140" y1="396" x2="300" y2="396" stroke="#b09020" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <text x="225" y="440" fill="#b09020" font-size="11" font-family="monospace" text-anchor="middle">e1 = b &#8722; a</text>
+  <line x1="144" y1="400" x2="144" y2="240" stroke="#b09020" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <text x="100" y="330" fill="#b09020" font-size="11" font-family="monospace" text-anchor="middle">e2 = c &#8722; a</text>
+
+  <!-- formula box -->
+  <rect x="390" y="180" width="230" height="64" rx="6" fill="rgba(32,112,192,0.1)" stroke="#2070c0" stroke-width="1.5"/>
+  <text x="402" y="204" fill="#2070c0" font-size="12" font-family="monospace">world(u,v) = a + e1&#183;u + e2&#183;v</text>
+  <text x="402" y="226" fill="#2070c0" font-size="10" font-family="monospace">texels = edge / unitsPerTexel</text>
+
+  <text x="320" y="462" fill="#aaa" font-size="10" font-family="monospace" text-anchor="middle">every texel owns a fixed patch of the triangle — shadows and gradients live INSIDE the surface</text>
+</svg>
diff --git a/doc/Global illumination/gi-converged.png b/doc/Global illumination/gi-converged.png
new file mode 100644 (file)
index 0000000..1f81bfd
Binary files /dev/null and b/doc/Global illumination/gi-converged.png differ
diff --git a/doc/Global illumination/gi-flat.png b/doc/Global illumination/gi-flat.png
new file mode 100644 (file)
index 0000000..f135bd2
Binary files /dev/null and b/doc/Global illumination/gi-flat.png differ
diff --git a/doc/Global illumination/gi-start.png b/doc/Global illumination/gi-start.png
new file mode 100644 (file)
index 0000000..6f7f3f0
Binary files /dev/null and b/doc/Global illumination/gi-start.png differ
diff --git a/doc/Global illumination/index.org b/doc/Global illumination/index.org
new file mode 100644 (file)
index 0000000..9568956
--- /dev/null
@@ -0,0 +1,251 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Global Illumination - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* What global illumination adds
+:PROPERTIES:
+:CUSTOM_ID: what-gi-adds
+:END:
+
+Plain per-polygon shading lights every polygon with one flat color:
+no shadows, and surfaces that receive no direct light stay uniformly
+dark. A room corner reads as a flat silhouette instead of a corner.
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:gi-flat.png]]
+
+*Aukio 3D* can optionally compute *global illumination* progressively
+on background CPU threads: shadows appear, light pools under lamps
+with smooth falloff, and colored light *bleeds* — a red sofa tints the
+floor next to it red. All of it converges gradually over the first
+seconds of a scene, then idles.
+
+Same camera, same house: flat shading (top) versus converged GI
+(below). Note the soft shadow of the partition wall, the lamp glow on
+the ceiling, and the subtle color variation across the floor.
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:gi-converged.png]]
+
+* The big idea: GI off the render path
+:PROPERTIES:
+:CUSTOM_ID: off-the-render-path
+:END:
+
+Ray tracing is far too slow to run per frame in a software renderer,
+so it doesn't: *the render loop never traces a single ray.* Painting a
+lightmapped triangle is an ordinary texture lookup, exactly as fast as
+any textured polygon.
+
+All the expensive work happens on dedicated low-priority worker threads
+that continuously refine per-surface lighting values. Whenever the
+values have improved enough, the workers regenerate each triangle's
+*composite texture* (baseColor x total lighting) into a back buffer
+and swap it in atomically — painters never see a half-updated texture.
+
+#+INCLUDE: "GI pipeline.svg" export html
+
+Because painters only read finished textures, frame rate is completely
+decoupled from GI quality: you can crank lightmap resolution up and the
+only cost is CPU time on the worker threads, not frame time.
+
+* Lightmaps: a texture per triangle
+:PROPERTIES:
+:CUSTOM_ID: lightmaps
+:END:
+
+Flat shading can only color a polygon uniformly — shadows and gradients
+need resolution *inside* the polygon. Every lightmapped triangle
+therefore owns a small generated texture, its *lightmap*, whose texels
+map onto the triangle surface by an affine rule:
+
+#+INCLUDE: "Lightmap mapping.svg" export html
+
+The triangle's UVs are pinned to (0,0), (1,0), (0,1), so the valid
+texel region is the half where u+v <= 1; the other half of the square
+texture is flood-filled from valid neighbors so that nearest sampling
+near the hypotenuse never picks up garbage.
+
+Each texel stores two things, both written only by GI threads:
+
+- *Indirect irradiance* (RGB floats) — the accumulated bounced light.
+- *Per-light visibility bits* — whether the last shadow ray from this
+  texel reached each lamp (cached so later queries cost nothing).
+
+Resolution is set in world units per texel
+(=LightmappedCompositeShape.setLightmapUnitsPerTexel()=, default 12): a 100-unit
+wall cell gets an 8x8 lightmap. Halving the units quadruples the
+tracing work.
+
+*What you trace is what you see:* the composite texture the painter
+samples is the lightmap itself, at native texel resolution — there is
+no upscaling step. Shadow-edge smoothness comes from tracing at finer
+resolution, never from interpolation. (An earlier bilinear-upscaling
+pass produced visibly artificial results and was removed; finer texels
+cost more CPU on the GI threads but look right.)
+
+* One sample: a shadow ray and a bounce ray
+:PROPERTIES:
+:CUSTOM_ID: one-sample
+:END:
+
+The work list is flat: one item per lightmap texel (and one per plain
+polygon, see below). Worker threads walk it round-robin, and every
+visit to a texel casts exactly two rays from the texel's world
+position, nudged slightly off the surface along the normal:
+
+1. *Shadow ray* toward a lamp. Answers visible/occluded, cached in the
+   texel's visibility bits. On a texel's *first* visit all lamps are
+   tested at once, so direct light and hard shadows appear after a
+   single sweep instead of trickling in lamp by lamp; later visits
+   re-test one lamp at a time, round-robin.
+2. *Bounce ray* in a random cosine-weighted direction around the
+   normal. Wherever it lands (point Q), the sample reads Q's *direct*
+   lighting — using Q's cached shadow bits, no new shadow rays — plus
+   Q's *current indirect estimate*, and blends the sum into the texel's
+   own indirect value.
+
+#+INCLUDE: "Bounce estimator.svg" export html
+
+Reading Q's current indirect estimate instead of recursing is what
+makes bounce light propagate: sweep 1 learns "Q is directly lit",
+sweep 2 learns "P sees a lit Q", sweep 3 learns "R sees a lit P"...
+Light ripples one surface deeper with every sweep, with no recursion
+limit and no exponential ray explosion. The =1/pi= diffuse gain keeps
+the feedback loop from diverging: without it the indirect term
+amplifies itself and the scene saturates to white.
+
+* Progressive convergence
+:PROPERTIES:
+:CUSTOM_ID: convergence
+:END:
+
+Monte Carlo samples are noisy, so blending happens at *two nested
+levels*, both exponential moving averages:
+
+1. *Inner, per sample*: each texel blends every new bounce-ray result
+   into its indirect estimate with a constant weight (=alpha = 0.15=,
+   mode =fixed=). Every ray hit stays equally intensive forever — an
+   unlit area fades to darkness at the same rate a lit area brightens.
+   (The old =adaptive= mode, which decays alpha with sample count, is
+   still available; see the knobs below.)
+2. *Outer, per composite update*: the value that reaches the screen is
+   a second EMA over the COMPLETE sum =ambient + direct + indirect=.
+   The texture can only move =e3d.gi.compositeAlpha= (default 0.2) of
+   the remaining distance per 500 ms update — so direct light, shadows
+   and bounce light all glide in together over a few seconds, and no
+   single-frame jump is possible by construction.
+
+The world starts at a *uniform medium irradiance*
+(=e3d.gi.initialIrradiance=, default 128): the scene is visible from
+the very first frame, then lit areas brighten and unlit areas sink to
+darkness as the workers sweep — lights and shadows gradually become
+distinguished instead of the old pitch-black start with a sudden flash
+once the first sweep landed:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:gi-start.png]]
+
+Consequences of the design:
+
+- *Hysteresis is free*: when a lamp moves or geometry changes, old
+  light fades out gradually instead of popping — the same pair of EMAs
+  that accumulates light also drains it.
+- *Convergence detection*: per-sample deltas are pure noise (and with a
+  constant alpha they never settle), so the system watches the average
+  per-texel movement of the on-screen estimate; after five composite
+  updates below =e3d.gi.calmThreshold= (default 1.0 light unit) the
+  workers drop to a low duty cycle (~50% of sweep time, capped) instead
+  of burning CPU. Any scene or light change rebuilds the snapshot and
+  restarts full-speed tracing.
+- *Despeckle*: at composite time the indirect channel is blended 50/50
+  with the mean of its valid 4-neighbors, killing single-texel Monte
+  Carlo spikes without blurring real gradients.
+- *Composite cadence*: textures regenerate at most every 500 ms — one
+  atomic swap per triangle, invisible to painters.
+
+* Plain polygons get GI too
+:PROPERTIES:
+:CUSTOM_ID: plain-polygons
+:END:
+
+Surfaces that are not lightmapped (ordinary =SolidPolygon= with shading
+enabled) still benefit, at per-polygon resolution, through the
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]]
+interface that =GlobalIllumination= installs into the
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]]:
+
+- =isLightVisible(polygon, light)= answers from cached shadow bits —
+  direct-light shadows fade in on flat-shaded geometry.
+- =addIndirectLight(polygon, baseColor, result)= adds the polygon's
+  bounced-light estimate into the flat-shaded color.
+
+Both are called from parallel render-pool threads, so they only read
+volatile caches — never trace.
+
+* Enabling GI
+:PROPERTIES:
+:CUSTOM_ID: enabling
+:END:
+
+#+BEGIN_SRC java
+// Per-texel lightmaps on composite geometry (the House demo setup):
+LightmappedCompositeShape house = new LightmappedCompositeShape();
+house.setLightmappingEnabled(true);
+house.setLightmapUnitsPerTexel(3.0);   // fine texels: quality from traced rays
+
+// Start the workers (2 threads by default; more converge faster):
+viewPanel.enableGlobalIllumination(4);
+#+END_SRC
+
+Tuning knobs (system properties):
+
+| Property                  | Default | Effect                                  |
+|---------------------------+---------+-----------------------------------------|
+| =e3d.gi.alphaMode=        | fixed   | =adaptive= decays the inner EMA alpha with sample count |
+| =e3d.gi.alphaFloor=       | 0.08    | adaptive-mode floor; higher adapts faster but noisier |
+| =e3d.gi.compositeAlpha=   | 0.2     | outer EMA: fade speed of the on-screen estimate per 500 ms update |
+| =e3d.gi.initialIrradiance=| 128     | uniform medium start (0..255 light units) |
+| =e3d.gi.calmThreshold=    | 1.0     | convergence: avg estimate movement (light units) |
+| =e3d.gi.despeckle=        | true    | neighbor-smoothing of indirect at composite time |
+| =e3d.gi.debug=            | false   | sweep statistics to stdout              |
+| =e3d.gi.dumpLightmaps=    | (unset) | dump composite lightmaps as PNGs to the given dir |
+
+* Limitations
+:PROPERTIES:
+:CUSTOM_ID: limitations
+:END:
+
+- *Diffuse light only* — no specular bounce, no caustics.
+- Polygon vertices are traced in composite-local space; scenes that put
+  non-identity transforms on composites are traced incorrectly.
+- The bounce estimate is one ray deep per sample — correctness comes
+  from sweep-over-sweep propagation, so deeply indirect corners take
+  several sweeps to brighten.
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                | Purpose                                                       |
+|----------------------+---------------------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.html][GlobalIllumination]] | Progressive tracer: sweeps, EMA convergence, composite swaps |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.html][Lightmap]]           | Per-triangle texel state + double-buffered composite textures |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.html][LightmappedTriangle]] | Textured triangle whose texture is the GI composite        |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.html][TriangleBvh]]        | BVH over world triangles: nearest-hit and any-hit ray queries (Möller–Trumbore) |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]]    | Cache-read interface feeding the flat-shading path          |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.html][LightmappedCompositeShape]]  | Wraps polygons into lightmapped triangles                     |
diff --git a/doc/Mesh.svg b/doc/Mesh.svg
new file mode 100644 (file)
index 0000000..8a20f46
--- /dev/null
@@ -0,0 +1,22 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <rect width="640" height="480" fill="#061018"/>
+  <ellipse cx="320" cy="240" rx="180" ry="180" fill="none" stroke="rgba(80,96,192,0.1)" stroke-width="1"/>
+  <ellipse cx="320" cy="240" rx="180" ry="40" fill="none" stroke="rgba(80,96,192,0.25)" stroke-width="1.6"/>
+  <ellipse cx="320" cy="180" rx="150" ry="32" fill="none" stroke="rgba(80,96,192,0.2)" stroke-width="1.2"/>
+  <ellipse cx="320" cy="300" rx="150" ry="32" fill="none" stroke="rgba(80,96,192,0.2)" stroke-width="1.2"/>
+  <ellipse cx="320" cy="120" rx="90" ry="20" fill="none" stroke="rgba(80,96,192,0.15)" stroke-width="1"/>
+  <ellipse cx="320" cy="360" rx="90" ry="20" fill="none" stroke="rgba(80,96,192,0.15)" stroke-width="1"/>
+  <ellipse cx="320" cy="240" rx="40" ry="180" fill="none" stroke="rgba(80,96,192,0.2)" stroke-width="1.2"/>
+  <ellipse cx="320" cy="240" rx="110" ry="180" fill="none" stroke="rgba(80,96,192,0.15)" stroke-width="1"/>
+  <polygon points="320,60 370,116 280,110" fill="rgba(80,96,192,0.15)" stroke="#5060c0" stroke-width="2"/>
+  <polygon points="370,116 410,176 320,164" fill="rgba(80,96,192,0.1)" stroke="#5060c0" stroke-width="1.6"/>
+  <polygon points="320,164 370,116 280,110" fill="rgba(80,96,192,0.07)" stroke="rgba(80,96,192,0.5)" stroke-width="1.2"/>
+  <circle cx="320" cy="60" r="5" fill="#5060c0"/>
+  <circle cx="370" cy="116" r="5" fill="#5060c0"/>
+  <circle cx="280" cy="110" r="5" fill="#5060c0"/>
+  <circle cx="410" cy="176" r="5" fill="#5060c0"/>
+  <circle cx="320" cy="164" r="5" fill="#5060c0"/>
+  <text x="436" y="140" fill="#5060c0" font-size="20" font-weight="600" font-family="monospace">triangulated</text>
+  <text x="436" y="164" fill="#5060c0" font-size="20" font-weight="600" font-family="monospace">section</text>
+  <line x1="412" y1="150" x2="428" y2="150" stroke="#5060c0" stroke-width="1.6"/>
+</svg>
diff --git a/doc/Near plane clip/Clip algorithm.svg b/doc/Near plane clip/Clip algorithm.svg
new file mode 100644 (file)
index 0000000..1d09a7e
--- /dev/null
@@ -0,0 +1,66 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+  <rect width="640" height="480" fill="#061018"/>
+
+  <!-- grid -->
+  <line x1="40" y1="120" x2="620" y2="120" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="200" x2="620" y2="200" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="280" x2="620" y2="280" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="360" x2="620" y2="360" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="160" y1="60" x2="160" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="300" y1="60" x2="300" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="440" y1="60" x2="440" y2="400" stroke="#1a3a4a" stroke-width="1"/>
+
+  <!-- half-space labels -->
+  <text x="170" y="84" fill="#FF4444" font-size="12" font-family="monospace" text-anchor="middle">behind  (z &#8804; near)</text>
+  <text x="460" y="84" fill="#30a050" font-size="12" font-family="monospace" text-anchor="middle">in front  (z &gt; near)</text>
+
+  <!-- near plane -->
+  <line x1="300" y1="95" x2="300" y2="400" stroke="#40b0d0" stroke-width="2" stroke-dasharray="6 3"/>
+  <text x="310" y="110" fill="#40b0d0" font-size="12" font-family="monospace" text-anchor="start" filter="url(#glow)">near plane</text>
+
+  <!-- discarded part of the triangle -->
+  <polygon points="120,260 300,200 300,320" fill="rgba(255,68,68,0.08)" stroke="#FF4444" stroke-width="1.5" stroke-dasharray="4 3"/>
+
+  <!-- kept fragment: the clipped quad -->
+  <polygon points="480,140 480,380 300,320 300,200" fill="rgba(48,160,80,0.12)" stroke="#39FF14" stroke-width="2" filter="url(#glow)"/>
+
+  <!-- original edges continuing behind the plane (ghost) -->
+  <line x1="300" y1="200" x2="120" y2="260" stroke="#c05088" stroke-width="1.5" stroke-dasharray="3 2"/>
+  <line x1="300" y1="320" x2="120" y2="260" stroke="#c05088" stroke-width="1.5" stroke-dasharray="3 2"/>
+
+  <!-- vertices -->
+  <circle cx="480" cy="140" r="6" fill="#c05088"/>
+  <text x="496" y="144" fill="#c05088" font-size="13" font-family="monospace">v0</text>
+  <circle cx="480" cy="380" r="6" fill="#c05088"/>
+  <text x="496" y="384" fill="#c05088" font-size="13" font-family="monospace">v1</text>
+  <circle cx="120" cy="260" r="6" fill="rgba(192,80,136,0.45)"/>
+  <text x="104" y="246" fill="#c05088" font-size="13" font-family="monospace" opacity="0.7">v2</text>
+
+  <!-- intersection vertices -->
+  <circle cx="300" cy="200" r="6" fill="#39FF14" filter="url(#glow)"/>
+  <text x="284" y="190" fill="#39FF14" font-size="13" font-family="monospace" text-anchor="end">p&#8242;</text>
+  <circle cx="300" cy="320" r="6" fill="#39FF14" filter="url(#glow)"/>
+  <text x="314" y="340" fill="#39FF14" font-size="13" font-family="monospace" text-anchor="start">p&#8243;</text>
+
+  <!-- t parameter marker on edge v0-v2, with leader line -->
+  <text x="210" y="140" fill="#FF8833" font-size="11" font-family="monospace" text-anchor="middle">t = 0.5 along v0 &#8594; v2</text>
+  <line x1="280" y1="146" x2="380" y2="166" stroke="#FF8833" stroke-width="1" stroke-dasharray="3 2"/>
+
+  <!-- formula box -->
+  <rect x="50" y="320" width="210" height="86" rx="6" fill="rgba(32,112,192,0.1)" stroke="#2070c0" stroke-width="1.5"/>
+  <text x="62" y="342" fill="#2070c0" font-size="12" font-family="monospace">t  = (near &#8722; z1) / (z2 &#8722; z1)</text>
+  <text x="62" y="362" fill="#2070c0" font-size="12" font-family="monospace">p  = p1 + t&#183;(p2 &#8722; p1)</text>
+  <text x="62" y="382" fill="#2070c0" font-size="12" font-family="monospace">uv = uv1 + t&#183;(uv2 &#8722; uv1)</text>
+
+  <text x="320" y="438" fill="#aaa" font-size="10" font-family="monospace" text-anchor="middle">every edge crossing the plane spawns an interpolated vertex;</text>
+  <text x="320" y="454" fill="#aaa" font-size="10" font-family="monospace" text-anchor="middle">in-front vertices pass through unchanged</text>
+</svg>
diff --git a/doc/Near plane clip/Fan triangulation.svg b/doc/Near plane clip/Fan triangulation.svg
new file mode 100644 (file)
index 0000000..94a8656
--- /dev/null
@@ -0,0 +1,39 @@
+<svg viewBox="0 0 620 240" width="620" height="240" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+  <rect width="620" height="240" fill="#061018"/>
+
+  <!-- clipped quad -->
+  <polygon points="120,50 330,50 400,180 170,180" fill="rgba(48,160,80,0.12)" stroke="#39FF14" stroke-width="2"/>
+
+  <!-- fan split from v0 -->
+  <line x1="120" y1="50" x2="400" y2="180" stroke="#FF6600" stroke-width="1.5" stroke-dasharray="6 3"/>
+
+  <!-- vertices -->
+  <circle cx="120" cy="50" r="5" fill="#c05088"/>
+  <text x="108" y="42" fill="#c05088" font-size="12" font-family="monospace" text-anchor="middle">v0</text>
+  <circle cx="330" cy="50" r="5" fill="#c05088"/>
+  <text x="330" y="42" fill="#c05088" font-size="12" font-family="monospace" text-anchor="middle">v1</text>
+  <circle cx="400" cy="180" r="5" fill="#39FF14" filter="url(#glow)"/>
+  <text x="408" y="198" fill="#39FF14" font-size="12" font-family="monospace">p&#8243;</text>
+  <circle cx="170" cy="180" r="5" fill="#39FF14" filter="url(#glow)"/>
+  <text x="162" y="198" fill="#39FF14" font-size="12" font-family="monospace" text-anchor="end">p&#8242;</text>
+
+  <!-- triangle labels -->
+  <text x="245" y="100" fill="#FF8833" font-size="12" font-family="monospace" text-anchor="middle">T1 = (v0, v1, p&#8243;)</text>
+  <text x="225" y="152" fill="#FF8833" font-size="12" font-family="monospace" text-anchor="middle">T2 = (v0, p&#8243;, p&#8242;)</text>
+
+  <!-- caption -->
+  <text x="460" y="60" fill="#aaa" font-size="11" font-family="monospace">a triangle cut once</text>
+  <text x="460" y="78" fill="#aaa" font-size="11" font-family="monospace">becomes a quad;</text>
+  <text x="460" y="96" fill="#aaa" font-size="11" font-family="monospace">the rasterizer paints it</text>
+  <text x="460" y="114" fill="#aaa" font-size="11" font-family="monospace">as a 2-triangle fan</text>
+  <text x="460" y="132" fill="#aaa" font-size="11" font-family="monospace">sharing v0</text>
+</svg>
diff --git a/doc/Near plane clip/Near plane straddle.svg b/doc/Near plane clip/Near plane straddle.svg
new file mode 100644 (file)
index 0000000..aa2c9a6
--- /dev/null
@@ -0,0 +1,57 @@
+<svg viewBox="0 0 620 300" width="620" height="300" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+  <rect width="620" height="300" fill="#061018"/>
+
+  <!-- grid -->
+  <line x1="40" y1="80" x2="600" y2="80" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="140" x2="600" y2="140" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="200" x2="600" y2="200" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="40" y1="260" x2="600" y2="260" stroke="#1a3a4a" stroke-width="1"/>
+
+  <!-- axes -->
+  <line x1="40" y1="270" x2="600" y2="270" stroke="#2070c0" stroke-width="2"/>
+  <text x="592" y="290" fill="#2070c0" font-size="12" font-family="monospace" text-anchor="end">z (depth) &#8594;</text>
+  <text x="44" y="46" fill="#d04040" font-size="12" font-family="monospace">x &#8595;</text>
+
+  <!-- camera eye -->
+  <circle cx="70" cy="200" r="10" fill="rgba(255,102,0,0.2)" stroke="#FF6600" stroke-width="2" filter="url(#glow)"/>
+  <circle cx="70" cy="200" r="3" fill="#FF6600"/>
+  <text x="70" y="232" fill="#FF8833" font-size="11" font-family="monospace" text-anchor="middle">camera</text>
+  <line x1="80" y1="200" x2="140" y2="200" stroke="#FF6600" stroke-width="1.5" stroke-dasharray="4 3"/>
+
+  <!-- near plane -->
+  <line x1="180" y1="55" x2="180" y2="262" stroke="#40b0d0" stroke-width="2" stroke-dasharray="6 3"/>
+  <text x="180" y="46" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">near plane  z = 1</text>
+
+  <!-- floor tiles seen edge-on (the floor is a row of quads at this x height) -->
+  <line x1="270" y1="200" x2="340" y2="200" stroke="#c05088" stroke-width="3"/>
+  <line x1="350" y1="200" x2="420" y2="200" stroke="#c05088" stroke-width="3"/>
+  <line x1="430" y1="200" x2="500" y2="200" stroke="#c05088" stroke-width="3"/>
+  <line x1="510" y1="200" x2="580" y2="200" stroke="#c05088" stroke-width="3"/>
+  <text x="460" y="186" fill="#c05088" font-size="11" font-family="monospace" text-anchor="middle">floor tiles</text>
+
+  <!-- the straddling tile: behind part discarded, front part kept -->
+  <line x1="130" y1="200" x2="180" y2="200" stroke="#FF4444" stroke-width="3" stroke-dasharray="4 3"/>
+  <line x1="180" y1="200" x2="260" y2="200" stroke="#39FF14" stroke-width="3.5" filter="url(#glow)"/>
+  <circle cx="180" cy="200" r="6" fill="#39FF14" filter="url(#glow)"/>
+  <text x="235" y="248" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">kept fragment</text>
+  <text x="128" y="248" fill="#FF4444" font-size="10" font-family="monospace" text-anchor="middle">cut away</text>
+
+  <!-- old behaviour callout -->
+  <text x="140" y="120" fill="#FF4444" font-size="11" font-family="monospace" text-anchor="middle">old: one vertex behind &#8658;</text>
+  <text x="140" y="136" fill="#FF4444" font-size="11" font-family="monospace" text-anchor="middle">whole tile dropped</text>
+  <line x1="150" y1="142" x2="165" y2="192" stroke="#FF4444" stroke-width="1" stroke-dasharray="3 2"/>
+
+  <!-- new behaviour callout -->
+  <text x="330" y="120" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">new: clip at the plane,</text>
+  <text x="330" y="136" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">paint the surviving fragment</text>
+  <line x1="260" y1="142" x2="205" y2="192" stroke="#39FF14" stroke-width="1" stroke-dasharray="3 2"/>
+</svg>
diff --git a/doc/Near plane clip/index.org b/doc/Near plane clip/index.org
new file mode 100644 (file)
index 0000000..0c59a70
--- /dev/null
@@ -0,0 +1,151 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Near-Plane Clipping - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* The problem
+:PROPERTIES:
+:CUSTOM_ID: the-problem
+:END:
+
+When the camera brushes against geometry — a floor tile under your
+feet, a wall you lean into — part of a polygon can end up *behind* the
+viewer while the rest stays in front. Perspective projection divides by
+depth (=screenX = x / z=), so a vertex at z ≤ 0 has no meaningful screen
+position at all.
+
+The naive way out — dropping any polygon that has even one vertex
+behind the camera — makes whole tiles vanish exactly when they are
+closest and largest on screen. Walking through the House demo, floor
+tiles blinked out of existence at the bottom of the frame:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:near-clip-before.png]]
+
+*Aukio 3D* instead *clips the polygon against the near plane* and
+renders the surviving fragment. The same frame with clipping enabled —
+the floor is solid to the bottom edge:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:near-clip-after.png]]
+
+Think of the camera plane as the edge of a table and the polygon as a
+sheet of paper partly hanging off it. Dropping the polygon means
+throwing away the whole sheet. Clipping takes scissors, cuts the sheet
+along the table edge, and keeps the part that lies on the table.
+
+#+INCLUDE: "Near plane straddle.svg" export html
+
+* Why not just clamp z?
+:PROPERTIES:
+:CUSTOM_ID: why-not-clamp-z
+:END:
+
+A tempting one-liner is to force every vertex to =z = max(z, epsilon)=
+and project anyway. It fails geometrically: a vertex at z = −50 clamped
+to z = 0.01 projects to a screen coordinate thousands of pixels away,
+*in the wrong direction* — the sign flip of the division mirrors it
+through the camera. The polygon smears into giant streaks across the
+frame instead of ending cleanly at the screen edge.
+
+Clipping produces the geometrically correct cut: the polygon's new edge
+lies exactly on the near plane, and everything the rasterizer receives
+has z > 0.
+
+* How the clipping works
+:PROPERTIES:
+:CUSTOM_ID: how-it-works
+:END:
+
+Clipping happens in *camera space*, after the transform stack has moved
+vertices relative to the viewer but *before* the perspective divide.
+The vertex loop is walked edge by edge (Sutherland-Hodgman style)
+against the plane =z = nearPlaneDistance=:
+
+1. An in-front vertex passes through unchanged.
+2. An edge that crosses the plane spawns a new vertex at the
+   intersection, with position, UV and normal all interpolated with the
+   same parameter =t=.
+3. A behind-plane vertex is skipped.
+
+#+INCLUDE: "Clip algorithm.svg" export html
+
+Interpolating UVs linearly along the 3D edge is exactly right for the
+perspective-correct texture mapper: the intersection vertex is a real
+point on the original edge, so its texture coordinate is the same blend
+of the endpoints' UVs. Textured fragments therefore show the correct
+texels right up to the cut, with no seam.
+
+Only a polygon with *all* vertices behind the plane is culled — the
+legitimate version of the old behavior.
+
+* From clipped loop to pixels
+:PROPERTIES:
+:CUSTOM_ID: from-clip-to-pixels
+:END:
+
+A convex N-gon crossing the plane clips to a single contiguous loop of
+at most N+1 vertices. For the triangle-based rasterizers this means a
+triangle can become a *quad*, which is painted as a two-triangle fan
+sharing the first vertex — exact, because the clip of a convex polygon
+stays convex:
+
+#+INCLUDE: "Fan triangulation.svg" export html
+
+Shape support:
+
+| Shape              | Behavior when straddling                          |
+|--------------------+---------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]]     | Clipped loop painted as triangle fan              |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] | Fan-painted with interpolated UVs (also inherited by lightmapped GI fragments) |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]]             | Shortened to the in-front endpoint + intersection |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.html][Billboard]]        | Single anchor point: culled when behind, as before |
+
+Implementation notes:
+
+- Clipped output is stored *per pipeline slot* on the shape
+  (=clippedVertices(ctx)=), so the triple-buffered pipeline can
+  transform frame N+1 while frame N is still painting.
+- Depth sorting and tile binning use the clipped vertices' average Z
+  and screen bounds — a clipped tile sorts as the fragment it became,
+  not as the polygon that reached behind you.
+- New intersection vertices exist only in camera space; they are
+  projected directly via
+  [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html#setCameraSpaceCoordinate(double,double,double,eu.svjatoslav.aukio.e3d.gui.RenderingContext)][Vertex.setCameraSpaceCoordinate()]],
+  bypassing the transform stack.
+
+* Configuration
+:PROPERTIES:
+:CUSTOM_ID: configuration
+:END:
+
+The near plane distance is a per-context knob, in world units:
+
+#+BEGIN_SRC java
+// Default is 1.0; smaller values let the camera press closer to
+// geometry before the scissors bite, at the cost of larger projected
+// coordinates for clipped fragments.
+viewPanel.getRenderingContext().nearPlaneDistance = 0.5;
+#+END_SRC
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                     | Purpose                                                        |
+|---------------------------+----------------------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]] | Vertex-loop clipping in =transform()=, per-slot clip storage   |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]]                  | Camera-space projection for generated intersection vertices    |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]]        | Carries =nearPlaneDistance=                                    |
diff --git a/doc/Near plane clip/near-clip-after.png b/doc/Near plane clip/near-clip-after.png
new file mode 100644 (file)
index 0000000..f135bd2
Binary files /dev/null and b/doc/Near plane clip/near-clip-after.png differ
diff --git a/doc/Near plane clip/near-clip-before.png b/doc/Near plane clip/near-clip-before.png
new file mode 100644 (file)
index 0000000..6b27c44
Binary files /dev/null and b/doc/Near plane clip/near-clip-before.png differ
diff --git a/doc/Normal vector.svg b/doc/Normal vector.svg
new file mode 100644 (file)
index 0000000..016136e
--- /dev/null
@@ -0,0 +1,18 @@
+<svg viewBox="0 0 640 520" width="640" height="520" xmlns="http://www.w3.org/2000/svg">
+  <rect width="640" height="520" fill="#061018"/>
+  <polygon points="120,400 320,360 520,400 320,440" fill="rgba(180,150,30,0.1)" stroke="rgba(180,150,30,0.4)" stroke-width="2"/>
+  <line x1="180" y1="396" x2="460" y2="396" stroke="rgba(180,150,30,0.08)" stroke-width="1"/>
+  <line x1="220" y1="388" x2="420" y2="388" stroke="rgba(180,150,30,0.06)" stroke-width="1"/>
+  <line x1="320" y1="396" x2="320" y2="120" stroke="#b09020" stroke-width="5"/>
+  <polygon points="320,120 310,144 330,144" fill="#b09020"/>
+  <path d="M320,396 L320,356 L340,360" fill="none" stroke="rgba(180,150,30,0.5)" stroke-width="2"/>
+  <text x="336" y="112" fill="#b09020" font-size="26" font-weight="700" font-family="monospace">N̂</text>
+  <text x="336" y="144" fill="#bbb" font-size="18" font-family="monospace">unit normal</text>
+  <text x="336" y="172" fill="#bbb" font-size="18" font-family="monospace">(perpendicular</text>
+  <text x="336" y="196" fill="#bbb" font-size="18" font-family="monospace"> to surface)</text>
+  <circle cx="140" cy="120" r="28" fill="rgba(180,150,30,0.08)" stroke="rgba(180,150,30,0.3)" stroke-width="2"/>
+  <circle cx="140" cy="120" r="8" fill="rgba(180,150,30,0.6)"/>
+  <text x="112" y="84" fill="#bbb" font-size="18" font-family="monospace">Light</text>
+  <line x1="160" y1="136" x2="300" y2="340" stroke="rgba(180,150,30,0.2)" stroke-width="2" stroke-dasharray="8 6"/>
+  <text x="164" y="284" fill="rgba(180,150,30,0.5)" font-size="18" font-family="monospace">L · N = brightness</text>
+</svg>
diff --git a/doc/Perspective correct textures/Adaptive interval.svg b/doc/Perspective correct textures/Adaptive interval.svg
new file mode 100644 (file)
index 0000000..924bc5c
--- /dev/null
@@ -0,0 +1,59 @@
+<svg viewBox="0 0 620 270" width="620" height="270" xmlns="http://www.w3.org/2000/svg">
+  <rect width="620" height="270" fill="#061018"/>
+
+  <!-- curvature curve -->
+  <polyline points="70.0,128.0 72.5,127.8 75.0,127.7 77.5,127.5 80.0,127.3 82.5,127.2 85.0,127.0 87.5,126.8 90.0,126.6 92.5,126.5 95.0,126.3 97.5,126.1 100.0,125.9 102.5,125.8 105.0,125.6 107.5,125.4 110.0,125.2 112.5,125.0 115.0,124.8 117.5,124.7 120.0,124.5 122.5,124.3 125.0,124.1 127.5,123.9 130.0,123.7 132.5,123.5 135.0,123.3 137.5,123.1 140.0,122.9 142.5,122.7 145.0,122.5 147.5,122.3 150.0,122.1 152.5,121.9 155.0,121.7 157.5,121.5 160.0,121.3 162.5,121.1 165.0,120.9 167.5,120.7 170.0,120.5 172.5,120.2 175.0,120.0 177.5,119.8 180.0,119.6 182.5,119.4 185.0,119.1 187.5,118.9 190.0,118.7 192.5,118.5 195.0,118.2 197.5,118.0 200.0,117.8 202.5,117.5 205.0,117.3 207.5,117.1 210.0,116.8 212.5,116.6 215.0,116.3 217.5,116.1 220.0,115.9 222.5,115.6 225.0,115.4 227.5,115.1 230.0,114.9 232.5,114.6 235.0,114.3 237.5,114.1 240.0,113.8 242.5,113.6 245.0,113.3 247.5,113.0 250.0,112.8 252.5,112.5 255.0,112.2 257.5,111.9 260.0,111.7 262.5,111.4 265.0,111.1 267.5,110.8 270.0,110.5 272.5,110.2 275.0,109.9 277.5,109.7 280.0,109.4 282.5,109.1 285.0,108.8 287.5,108.5 290.0,108.2 292.5,107.8 295.0,107.5 297.5,107.2 300.0,106.9 302.5,106.6 305.0,106.3 307.5,105.9 310.0,105.6 312.5,105.3 315.0,105.0 317.5,104.6 320.0,104.3 322.5,103.9 325.0,103.6 327.5,103.3 330.0,102.9 332.5,102.6 335.0,102.2 337.5,101.8 340.0,101.5 342.5,101.1 345.0,100.7 347.5,100.4 350.0,100.0 352.5,99.6 355.0,99.2 357.5,98.9 360.0,98.5 362.5,98.1 365.0,97.7 367.5,97.3 370.0,96.9 372.5,96.5 375.0,96.1 377.5,95.6 380.0,95.2 382.5,94.8 385.0,94.4 387.5,93.9 390.0,93.5 392.5,93.1 395.0,92.6 397.5,92.2 400.0,91.7 402.5,91.3 405.0,90.8 407.5,90.3 410.0,89.9 412.5,89.4 415.0,88.9 417.5,88.4 420.0,87.9 422.5,87.4 425.0,86.9 427.5,86.4 430.0,85.9 432.5,85.4 435.0,84.9 437.5,84.3 440.0,83.8 442.5,83.3 445.0,82.7 447.5,82.2 450.0,81.6 452.5,81.1 455.0,80.5 457.5,79.9 460.0,79.3 462.5,78.7 465.0,78.1 467.5,77.5 470.0,76.9 472.5,76.3 475.0,75.7 477.5,75.0 480.0,74.4 482.5,73.8 485.0,73.1 487.5,72.4 490.0,71.8 492.5,71.1 495.0,70.4 497.5,69.7 500.0,69.0 502.5,68.3 505.0,67.6 507.5,66.8 510.0,66.1 512.5,65.4 515.0,64.6 517.5,63.8 520.0,63.0 522.5,62.3 525.0,61.5 527.5,60.6 530.0,59.8 532.5,59.0 535.0,58.1 537.5,57.3 540.0,56.4 542.5,55.5 545.0,54.7 547.5,53.7 550.0,52.8 552.5,51.9 555.0,51.0 557.5,50.0 560.0,49.0 562.5,48.0 565.0,47.0 567.5,46.0 570.0,45.0" fill="none" stroke="#c05088" stroke-width="2"/>
+  <text x="80.0" y="34" fill="#c05088" font-size="10" font-family="monospace">perspective curvature &#8594; rises toward the far end</text>
+
+  <!-- span bar with adaptive blocks -->
+  <rect x="70.0" y="150" width="80.0" height="34" fill="rgba(48,160,80,0.45)" stroke="#30a050" stroke-width="1"/>
+  <rect x="150.0" y="150" width="80.0" height="34" fill="rgba(48,160,80,0.45)" stroke="#30a050" stroke-width="1"/>
+  <rect x="230.0" y="150" width="80.0" height="34" fill="rgba(48,160,80,0.45)" stroke="#30a050" stroke-width="1"/>
+  <rect x="310.0" y="150" width="40.0" height="34" fill="rgba(221,153,0,0.45)" stroke="#dd9900" stroke-width="1"/>
+  <rect x="350.0" y="150" width="40.0" height="34" fill="rgba(221,153,0,0.45)" stroke="#dd9900" stroke-width="1"/>
+  <rect x="390.0" y="150" width="20.0" height="34" fill="rgba(255,102,0,0.45)" stroke="#FF6600" stroke-width="1"/>
+  <rect x="410.0" y="150" width="20.0" height="34" fill="rgba(255,102,0,0.45)" stroke="#FF6600" stroke-width="1"/>
+  <rect x="430.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="440.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="450.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="460.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="470.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="480.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="490.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="500.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="510.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="520.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="530.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="540.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="550.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <rect x="560.0" y="150" width="10.0" height="34" fill="rgba(208,64,64,0.45)" stroke="#d04040" stroke-width="1"/>
+  <line x1="70.0" y1="146" x2="70.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="150.0" y1="146" x2="150.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="230.0" y1="146" x2="230.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="310.0" y1="146" x2="310.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="350.0" y1="146" x2="350.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="390.0" y1="146" x2="390.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="410.0" y1="146" x2="410.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="430.0" y1="146" x2="430.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="440.0" y1="146" x2="440.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="450.0" y1="146" x2="450.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="460.0" y1="146" x2="460.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="470.0" y1="146" x2="470.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="480.0" y1="146" x2="480.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="490.0" y1="146" x2="490.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="500.0" y1="146" x2="500.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="510.0" y1="146" x2="510.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="520.0" y1="146" x2="520.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="530.0" y1="146" x2="530.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="540.0" y1="146" x2="540.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="550.0" y1="146" x2="550.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="560.0" y1="146" x2="560.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <line x1="570.0" y1="146" x2="570.0" y2="150" stroke="#aaa" stroke-width="1"/>
+  <text x="190.0" y="202" fill="#30a050" font-size="11" font-family="monospace" text-anchor="middle">16 px</text>
+  <text x="350.0" y="218" fill="#dd9900" font-size="11" font-family="monospace" text-anchor="middle">8 px</text>
+  <text x="410.0" y="202" fill="#FF6600" font-size="11" font-family="monospace" text-anchor="middle">4 px</text>
+  <text x="500.0" y="218" fill="#d04040" font-size="11" font-family="monospace" text-anchor="middle">2 px</text>
+
+  <text x="70" y="248" fill="#666" font-size="10" font-family="monospace">one scanline, 100 px &#8594;</text>
+  <text x="570" y="248" fill="#666" font-size="10" font-family="monospace" text-anchor="end">grazing-angle floor, far side</text>
+</svg>
diff --git a/doc/Perspective correct textures/Affine distortion.png b/doc/Perspective correct textures/Affine distortion.png
new file mode 100644 (file)
index 0000000..8d3722b
Binary files /dev/null and b/doc/Perspective correct textures/Affine distortion.png differ
diff --git a/doc/Perspective correct textures/Scanline correction.svg b/doc/Perspective correct textures/Scanline correction.svg
new file mode 100644 (file)
index 0000000..cc5fc8d
--- /dev/null
@@ -0,0 +1,41 @@
+<svg viewBox="0 0 620 280" width="620" height="280" xmlns="http://www.w3.org/2000/svg">
+  <rect width="620" height="280" fill="#061018"/>
+
+  <!-- grid -->
+  <line x1="70" y1="185.0" x2="570" y2="185.0" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="70" y1="140.0" x2="570" y2="140.0" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="70" y1="95.0" x2="570" y2="95.0" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="195.0" y1="50" x2="195.0" y2="230" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="320.0" y1="50" x2="320.0" y2="230" stroke="#1a3a4a" stroke-width="1"/>
+  <line x1="445.0" y1="50" x2="445.0" y2="230" stroke="#1a3a4a" stroke-width="1"/>
+
+  <!-- axes -->
+  <line x1="70" y1="230" x2="570" y2="230" stroke="#445566" stroke-width="1.5"/>
+  <line x1="70" y1="230" x2="70" y2="50" stroke="#445566" stroke-width="1.5"/>
+  <text x="570" y="252" fill="#666" font-size="10" font-family="monospace" text-anchor="end">screen pixel &#8594;</text>
+  <text x="62" y="42" fill="#666" font-size="10" font-family="monospace" text-anchor="start">texel u &#8593;</text>
+
+  <!-- affine (straight, wrong) -->
+  <polyline points="70.0,230.0 570.0,50.0" fill="none" stroke="#c05088" stroke-width="2" stroke-dasharray="6 4"/>
+
+  <!-- exact perspective curve -->
+  <polyline points="70.0,230.0 72.5,229.6 75.0,229.3 77.5,228.9 80.0,228.5 82.5,228.2 85.0,227.8 87.5,227.4 90.0,227.0 92.5,226.7 95.0,226.3 97.5,225.9 100.0,225.5 102.5,225.1 105.0,224.7 107.5,224.3 110.0,223.9 112.5,223.6 115.0,223.2 117.5,222.7 120.0,222.3 122.5,221.9 125.0,221.5 127.5,221.1 130.0,220.7 132.5,220.3 135.0,219.8 137.5,219.4 140.0,219.0 142.5,218.6 145.0,218.1 147.5,217.7 150.0,217.3 152.5,216.8 155.0,216.4 157.5,215.9 160.0,215.5 162.5,215.0 165.0,214.6 167.5,214.1 170.0,213.6 172.5,213.2 175.0,212.7 177.5,212.2 180.0,211.8 182.5,211.3 185.0,210.8 187.5,210.3 190.0,209.8 192.5,209.3 195.0,208.8 197.5,208.3 200.0,207.8 202.5,207.3 205.0,206.8 207.5,206.3 210.0,205.8 212.5,205.2 215.0,204.7 217.5,204.2 220.0,203.7 222.5,203.1 225.0,202.6 227.5,202.0 230.0,201.5 232.5,200.9 235.0,200.4 237.5,199.8 240.0,199.2 242.5,198.7 245.0,198.1 247.5,197.5 250.0,196.9 252.5,196.4 255.0,195.8 257.5,195.2 260.0,194.6 262.5,194.0 265.0,193.3 267.5,192.7 270.0,192.1 272.5,191.5 275.0,190.8 277.5,190.2 280.0,189.6 282.5,188.9 285.0,188.3 287.5,187.6 290.0,187.0 292.5,186.3 295.0,185.6 297.5,184.9 300.0,184.3 302.5,183.6 305.0,182.9 307.5,182.2 310.0,181.5 312.5,180.7 315.0,180.0 317.5,179.3 320.0,178.6 322.5,177.8 325.0,177.1 327.5,176.3 330.0,175.6 332.5,174.8 335.0,174.0 337.5,173.3 340.0,172.5 342.5,171.7 345.0,170.9 347.5,170.1 350.0,169.3 352.5,168.5 355.0,167.6 357.5,166.8 360.0,166.0 362.5,165.1 365.0,164.2 367.5,163.4 370.0,162.5 372.5,161.6 375.0,160.7 377.5,159.8 380.0,158.9 382.5,158.0 385.0,157.1 387.5,156.1 390.0,155.2 392.5,154.2 395.0,153.3 397.5,152.3 400.0,151.3 402.5,150.3 405.0,149.3 407.5,148.3 410.0,147.3 412.5,146.3 415.0,145.2 417.5,144.2 420.0,143.1 422.5,142.0 425.0,140.9 427.5,139.8 430.0,138.7 432.5,137.6 435.0,136.5 437.5,135.3 440.0,134.2 442.5,133.0 445.0,131.8 447.5,130.6 450.0,129.4 452.5,128.2 455.0,127.0 457.5,125.7 460.0,124.4 462.5,123.2 465.0,121.9 467.5,120.6 470.0,119.2 472.5,117.9 475.0,116.5 477.5,115.2 480.0,113.8 482.5,112.4 485.0,111.0 487.5,109.5 490.0,108.1 492.5,106.6 495.0,105.1 497.5,103.6 500.0,102.1 502.5,100.5 505.0,99.0 507.5,97.4 510.0,95.8 512.5,94.1 515.0,92.5 517.5,90.8 520.0,89.1 522.5,87.4 525.0,85.7 527.5,83.9 530.0,82.1 532.5,80.3 535.0,78.5 537.5,76.7 540.0,74.8 542.5,72.9 545.0,70.9 547.5,69.0 550.0,67.0 552.5,65.0 555.0,62.9 557.5,60.8 560.0,58.7 562.5,56.6 565.0,54.4 567.5,52.2 570.0,50.0" fill="none" stroke="#39FF14" stroke-width="2"/>
+
+  <!-- subdivided correction -->
+  <polyline points="70.0,230.0 150.0,217.3 230.0,201.5 310.0,181.5 390.0,155.2 470.0,119.2 570.0,50.0" fill="none" stroke="#FF6600" stroke-width="2"/>
+  <circle cx="70.0" cy="230.0" r="3.5" fill="#FF6600"/>
+  <circle cx="150.0" cy="217.3" r="3.5" fill="#FF6600"/>
+  <circle cx="230.0" cy="201.5" r="3.5" fill="#FF6600"/>
+  <circle cx="310.0" cy="181.5" r="3.5" fill="#FF6600"/>
+  <circle cx="390.0" cy="155.2" r="3.5" fill="#FF6600"/>
+  <circle cx="470.0" cy="119.2" r="3.5" fill="#FF6600"/>
+  <circle cx="570.0" cy="50.0" r="3.5" fill="#FF6600"/>
+
+  <!-- legend -->
+  <line x1="380" y1="20" x2="410" y2="20" stroke="#39FF14" stroke-width="2"/>
+  <text x="416" y="24" fill="#39FF14" font-size="10" font-family="monospace">exact perspective</text>
+  <line x1="380" y1="38" x2="410" y2="38" stroke="#FF6600" stroke-width="2"/>
+  <text x="416" y="42" fill="#FF6600" font-size="10" font-family="monospace">corrected every 16 px</text>
+  <line x1="380" y1="56" x2="410" y2="56" stroke="#c05088" stroke-width="2" stroke-dasharray="6 4"/>
+  <text x="416" y="60" fill="#c05088" font-size="10" font-family="monospace">plain affine</text>
+</svg>
diff --git a/doc/Perspective correct textures/index.org b/doc/Perspective correct textures/index.org
new file mode 100644 (file)
index 0000000..3438f01
--- /dev/null
@@ -0,0 +1,177 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Perspective-Correct Textures - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* The problem
+:PROPERTIES:
+:CUSTOM_ID: introduction
+:ID:       a2b3c4d5-e6f7-8901-bcde-f23456789012
+:END:
+
+When a textured polygon is rendered at an angle to the viewer, naive
+linear interpolation of texture coordinates produces visible
+distortion.
+
+Consider a large textured floor extending toward the horizon. Without
+perspective correction, the texture appears to "swim" or distort
+because the texture coordinates are interpolated linearly across
+screen space, not accounting for depth.
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Affine distortion.png]]
+
+The *Aukio 3D* engine solves this with *subdivided perspective
+correction* inside the scanline rasterizer — the same technique Quake
+used.
+
+* How perspective correction works
+:PROPERTIES:
+:CUSTOM_ID: how-perspective-correction-works
+:END:
+
+Texture coordinates (u, v) are not linear in screen space, so they
+cannot simply be stepped per pixel. But divide them by depth and they
+become linear: *(u/z, v/z, 1/z) all interpolate linearly* across the
+triangle in screen space.
+
+The rasterizer exploits this:
+
+1. Compute (u/z, v/z, 1/z) at each vertex
+2. Interpolate all three across the scanline with plain additions
+3. Every N pixels, recover the exact texture coordinate with one
+   division: =u = (u/z) / (1/z)=
+4. Between correction points, step u/v affinely toward the next exact
+   point
+
+#+INCLUDE: "Scanline correction.svg" export html
+
+The orange polyline hugs the exact green curve: within each 16-pixel
+block it is a straight line, but every block starts exactly on the
+curve. The dashed pink line is plain affine interpolation — visibly
+wrong everywhere except the endpoints.
+
+Think of it like walking with a map that is slightly distorted: you
+walk in a straight line, but every 16 steps you check a landmark and
+correct your course. The correction (a division) costs something, so
+you do it every N pixels instead of every pixel — the divide cost is
+amortized to 1/16th of a per-pixel-correct rasterizer.
+
+#+BEGIN_SRC java
+// Per scanline (simplified from drawHorizontalLinePerspective):
+while (done < span) {
+    // Advance (u/z, v/z, 1/z) to the end of this block
+    su += dsu * block;  sv += dsv * block;  sw += dsw * block;
+    double txNext = su / sw;   // one reciprocal = exact texture position
+    double tyNext = sv / sw;
+
+    // Step affinely through the block
+    double txStep = (txNext - tx) / block;
+    double tyStep = (tyNext - ty) / block;
+    for (int i = 0; i < block; i++) {
+        plot(x++, texture.sample(tx, ty));
+        tx += txStep;  ty += tyStep;
+    }
+    done += block;
+}
+#+END_SRC
+
+** Adaptive correction interval
+
+Quake used a fixed 16-pixel interval. This engine keeps 16 as the
+default but *shrinks the interval when the error bound demands it*.
+
+The error of affine stepping within a block grows with both the
+texture gradient (texels per pixel) and the perspective curvature
+(how fast 1/z changes across the span). Each scanline computes
+
+#+BEGIN_EXAMPLE
+error(texels) ≈ texelRate · interval² · k / 2
+#+END_EXAMPLE
+
+where =k = |d(1/z)| / min(1/z)= is the per-pixel relative depth change,
+and picks the largest power-of-two interval from the ladder 16, 8, 4,
+2, 1 that keeps the bound under half a texel. Flat, gently angled
+spans keep the fast 16-pixel cadence; a floor tile seen at a grazing
+angle drops to shorter intervals exactly where the curvature is high.
+
+#+INCLUDE: "Adaptive interval.svg" export html
+
+** When affine is good enough
+
+For small or nearly flat triangles, plain affine mapping is already
+within half a texel of exact perspective, so the perspective setup is
+skipped entirely. The test compares the texture range the triangle
+covers against its depth variation:
+
+#+BEGIN_EXAMPLE
+affine is sufficient when  texelSpan · (zMax/zMin − 1) < 2
+#+END_EXAMPLE
+
+Note the criterion is the *texel* span, not the pixel size — a tiny
+on-screen triangle can still map many texels into few pixels. Distant
+clusters of small triangles (a common case) all render through the
+cheaper affine path.
+
+Triangles straddling the near plane (any vertex closer than z = 0.001)
+also fall back to affine, because 1/z interpolation is invalid there.
+
+** Toggling the correction
+
+Perspective correction can be switched off globally for A/B comparison
+or debugging:
+
+#+BEGIN_SRC java
+TexturedTriangle.setPerspectiveCorrectionEnabled(false);  // plain affine everywhere
+#+END_SRC
+
+With correction disabled, large triangles at steep angles visibly warp
+— useful for demonstrating what the correction actually buys.
+
+* Mipmap selection
+:PROPERTIES:
+:CUSTOM_ID: mipmap-selection
+:END:
+
+Perspective correction fixes *where* a texel is sampled; mipmapping
+decides *which resolution* to sample from. Each triangle estimates its
+screen-pixels-per-texel ratio from edge lengths:
+
+#+BEGIN_EXAMPLE
+scaleFactor = (sum of screen edge lengths) / (sum of UV edge lengths) · 1.2
+#+END_EXAMPLE
+
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html#getMipmapForScale(double)][Texture.getMipmapForScale()]]
+then picks the lazily-generated mipmap level closest to that scale:
+halved resolutions when the texture is minified, doubled when strongly
+magnified. Sampling a smaller mipmap under minification both speeds up
+rendering (better cache behavior) and reduces aliasing.
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                         | Purpose                                                            |
+|-------------------------------+--------------------------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]]              | Textured triangle with perspective-correct and SDF rendering paths |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.html][PerspectiveBorderInterpolator]] | Edge walker interpolating (u/z, v/z, 1/z) along triangle borders   |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.html][PolygonBorderInterpolator]]     | Edge walker for plain affine mapping                               |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html][Texture]]                       | Mipmap container; also carries the SDF mask and color layers       |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html][TextureBitmap]]                 | Raw pixel array for one mipmap level                               |
+
+*See also:*
+
+- [[file:../SDF textures/][SDF textures]] — signed-distance-field glyph rendering, the
+  alternative sampling path in =TexturedTriangle= that reuses the same
+  perspective-correct interpolation for crisp text at any angle.
diff --git a/doc/Point3D vertex.svg b/doc/Point3D vertex.svg
new file mode 100644 (file)
index 0000000..0954bac
--- /dev/null
@@ -0,0 +1,105 @@
+<svg width="100%" viewBox="0 0 680 320" xmlns="http://www.w3.org/2000/svg"><rect width="680" height="320" fill="#061018"/><defs><mask id="imagine-text-gaps-eqff6y" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="680" height="350" fill="white"/><rect x="134.36932373046875" y="19.672515869140625" width="71.26135635375977" height="22.184885025024414" fill="black" rx="2"/><rect x="120.89203643798828" y="40.63203048706055" width="98.2159194946289" height="16.12325668334961" fill="black" rx="2"/><rect x="182" y="129.87673950195312" width="78.42510223388672" height="19.42959976196289" fill="black" rx="2"/><rect x="-4.000310796312988" y="66.63202667236328" width="104.23031616210938" height="16.12325668334961" fill="black" rx="2"/><rect x="268" y="66.63202667236328" width="62.12955093383789" height="16.12325668334961" fill="black" rx="2"/><rect x="26.07363510131836" y="132.6320343017578" width="44.086368560791016" height="16.12325668334961" fill="black" rx="2"/><rect x="-3.9983373035211116" y="146.6320343017578" width="74.15834045410156" height="16.12325668334961" fill="black" rx="2"/><rect x="268" y="140.6320343017578" width="98.2159194946289" height="16.12325668334961" fill="black" rx="2"/><rect x="50.129241943359375" y="198.6320343017578" width="50.10076141357422" height="16.12325668334961" fill="black" rx="2"/><rect x="56.14363479614258" y="212.6320343017578" width="44.086368560791016" height="16.12325668334961" fill="black" rx="2"/><rect x="268" y="206.6320343017578" width="74.15834045410156" height="16.12325668334961" fill="black" rx="2"/><rect x="108.86325073242188" y="268.63201904296875" width="122.27349853515625" height="16.12325668334961" fill="black" rx="2"/><rect x="93.82726287841797" y="284.63201904296875" width="152.34547424316406" height="16.12325668334961" fill="black" rx="2"/><rect x="478.88800048828125" y="19.672515869140625" width="62.224021911621094" height="22.184885025024414" fill="black" rx="2"/><rect x="436.83447265625" y="40.63203048706055" width="146.33108520507812" height="16.12325668334961" fill="black" rx="2"/><rect x="414" y="89.52991485595703" width="74.28429412841797" height="17.225370407104492" fill="black" rx="2"/><rect x="544" y="91.18309020996094" width="45.9127311706543" height="15.021142959594727" fill="black" rx="2"/><rect x="352" y="108.28520202636719" width="32.089067459106445" height="13.919028282165527" fill="black" rx="2"/><rect x="402" y="129.52992248535156" width="147.19703674316406" height="17.225370407104492" fill="black" rx="2"/><rect x="402" y="169.52992248535156" width="127.31173706054688" height="17.225370407104492" fill="black" rx="2"/><rect x="402" y="209.52992248535156" width="120.68330383300781" height="17.225370407104492" fill="black" rx="2"/><rect x="594" y="211.18309020996094" width="18.832207679748535" height="15.021142959594727" fill="black" rx="2"/><rect x="402" y="249.52992248535156" width="47.77057647705078" height="17.225370407104492" fill="black" rx="2"/><rect x="473" y="251.18309020996094" width="45.9127311706543" height="15.021142959594727" fill="black" rx="2"/><rect x="612.9194946289062" y="87.18309020996094" width="35.08052062988281" height="15.021142959594727" fill="black" rx="2"/><rect x="607.5033569335938" y="127.18309020996094" width="40.49662780761719" height="15.021142959594727" fill="black" rx="2"/><rect x="612.9194946289062" y="137.18309020996094" width="35.08052062988281" height="15.021142959594727" fill="black" rx="2"/><rect x="629.1677856445312" y="167.18309020996094" width="18.832207679748535" height="15.021142959594727" fill="black" rx="2"/><rect x="607.5033569335938" y="177.18309020996094" width="40.49662780761719" height="15.021142959594727" fill="black" rx="2"/><rect x="647.0900268554688" y="80.28520202636719" width="32.089067459106445" height="13.919028282165527" fill="black" rx="2"/><rect x="647.0900268554688" y="260.2851867675781" width="36.90688133239746" height="13.919028282165527" fill="black" rx="2"/><rect x="385.71209716796875" y="298.63201904296875" width="248.57579040527344" height="16.12325668334961" fill="black" rx="2"/></mask></defs>
+
+
+<!-- Divider -->
+<line x1="340" y1="30" x2="340" y2="310" stroke="#1a2a38" stroke-width="1" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(26, 42, 56);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+
+<!-- ===== LEFT: Point3D ===== -->
+<text x="170" y="36" text-anchor="middle" fill="#2070c0" font-size="15" font-weight="700" font-family="monospace" style="fill:rgb(32, 112, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:auto">Point3D</text>
+<text x="170" y="52" text-anchor="middle" fill="#556677" font-size="10" font-family="monospace" style="fill:rgb(85, 102, 119);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:middle;dominant-baseline:auto">raw coordinates</text>
+
+<!-- Glow rings + point -->
+<circle cx="170" cy="148" r="36" fill="rgba(56,140,248,0.04)" stroke="none" style="fill:rgba(56, 140, 248, 0.04);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="170" cy="148" r="20" fill="rgba(56,140,248,0.08)" stroke="none" style="fill:rgba(56, 140, 248, 0.08);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="170" cy="148" r="8" fill="rgba(56,140,248,0.2)" stroke="none" style="fill:rgba(56, 140, 248, 0.2);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="170" cy="148" r="4" fill="#2070c0" style="fill:rgb(32, 112, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="186" y="144" fill="#2070c0" font-size="13" font-weight="700" font-family="monospace" style="fill:rgb(32, 112, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:13px;font-weight:700;text-anchor:start;dominant-baseline:auto">(x, y, z)</text>
+
+<!-- Radial leader lines + labels — 6 directions, all consistent -->
+<!-- Top-left: distance -->
+<line x1="155" y1="132" x2="68" y2="82" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="68" cy="82" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="96.23" y="78" text-anchor="end" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:end;dominant-baseline:auto">.getDistanceTo()</text>
+
+<!-- Top-right: rotate -->
+<line x1="188" y1="134" x2="268" y2="82" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="268" cy="82" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="272" y="78" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:start;dominant-baseline:auto">.rotate()</text>
+
+<!-- Left: add/subtract -->
+<line x1="150" y1="148" x2="58" y2="148" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="58" cy="148" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="66.16" y="144" text-anchor="end" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:end;dominant-baseline:auto">.add()</text>
+<text x="66.16" y="158" text-anchor="end" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:end;dominant-baseline:auto">.subtract()</text>
+
+<!-- Right: crossProduct -->
+<line x1="190" y1="148" x2="268" y2="148" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="268" cy="148" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="272" y="152" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:start;dominant-baseline:auto">.crossProduct()</text>
+
+<!-- Bottom-left: unit/dot -->
+<line x1="155" y1="164" x2="68" y2="214" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="68" cy="214" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="96.23" y="210" text-anchor="end" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:end;dominant-baseline:auto">.unit()</text>
+<text x="96.23" y="224" text-anchor="end" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:end;dominant-baseline:auto">.dot()</text>
+
+<!-- Bottom-right: multiply -->
+<line x1="188" y1="162" x2="268" y2="214" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="3 2" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:3px, 2px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="268" cy="214" r="1.5" fill="#2a3a4a" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="272" y="218" fill="#445566" font-size="10" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:start;dominant-baseline:auto">.multiply()</text>
+
+<!-- Summary -->
+<text x="170" y="280" text-anchor="middle" fill="#556677" font-size="10" font-family="monospace" style="fill:rgb(85, 102, 119);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Mutable, fluent API</text>
+<text x="170" y="296" text-anchor="middle" fill="#556677" font-size="10" font-family="monospace" style="fill:rgb(85, 102, 119);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Positions, vectors, math</text>
+
+<!-- ===== RIGHT: Vertex ===== -->
+<text x="510" y="36" text-anchor="middle" fill="#c05088" font-size="15" font-weight="700" font-family="monospace" style="fill:rgb(192, 80, 136);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:15px;font-weight:700;text-anchor:middle;dominant-baseline:auto">Vertex</text>
+<text x="510" y="52" text-anchor="middle" fill="#556677" font-size="10" font-family="monospace" style="fill:rgb(85, 102, 119);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:middle;dominant-baseline:auto">rendering-ready wrapper</text>
+
+<!-- Outer wrapper box -->
+<rect x="375" y="68" width="270" height="230" rx="6" fill="rgba(192,80,136,0.04)" stroke="rgba(192,80,136,0.2)" stroke-width="0.5" style="fill:rgba(192, 80, 136, 0.04);stroke:rgba(192, 80, 136, 0.2);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+
+<!-- coordinate (Point3D) -->
+<rect x="390" y="82" width="240" height="32" rx="4" fill="rgba(56,140,248,0.1)" stroke="rgba(56,140,248,0.3)" stroke-width="0.5" style="fill:rgba(56, 140, 248, 0.1);stroke:rgba(56, 140, 248, 0.3);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<circle cx="406" cy="98" r="3" fill="#2070c0" style="fill:rgb(32, 112, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="418" y="102" fill="#2070c0" font-size="11" font-weight="700" font-family="monospace" style="fill:rgb(32, 112, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:11px;font-weight:700;text-anchor:start;dominant-baseline:auto">coordinate</text>
+<text x="548" y="102" fill="#445566" font-size="9" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:start;dominant-baseline:auto">Point3D</text>
+
+<!-- "wraps" arrow -->
+<line x1="340" y1="148" x2="388" y2="98" stroke="#2a3a4a" stroke-width="0.5" stroke-dasharray="4 3" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgb(42, 58, 74);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-dasharray:4px, 3px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="356" y="118" fill="#334455" font-size="8" font-family="monospace" transform="rotate(-30 356 118)" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:start;dominant-baseline:auto">wraps</text>
+
+<!-- transformedCoordinate -->
+<rect x="390" y="122" width="240" height="32" rx="4" fill="rgba(48,160,80,0.08)" stroke="rgba(48,160,80,0.25)" stroke-width="0.5" style="fill:rgba(48, 160, 80, 0.08);stroke:rgba(48, 160, 80, 0.25);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="406" y="142" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" style="fill:rgb(48, 160, 80);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:11px;font-weight:700;text-anchor:start;dominant-baseline:auto">transformedCoordinate</text>
+
+<!-- onScreenCoordinate -->
+<rect x="390" y="162" width="240" height="32" rx="4" fill="rgba(255,102,0,0.08)" stroke="rgba(255,102,0,0.25)" stroke-width="0.5" style="fill:rgba(255, 102, 0, 0.08);stroke:rgba(255, 102, 0, 0.25);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="406" y="182" fill="#FF6600" font-size="11" font-weight="700" font-family="monospace" style="fill:rgb(255, 102, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:11px;font-weight:700;text-anchor:start;dominant-baseline:auto">onScreenCoordinate</text>
+
+<!-- textureCoordinate -->
+<rect x="390" y="202" width="240" height="32" rx="4" fill="rgba(176,144,32,0.08)" stroke="rgba(176,144,32,0.25)" stroke-width="0.5" style="fill:rgba(176, 144, 32, 0.08);stroke:rgba(176, 144, 32, 0.25);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="406" y="222" fill="#b09020" font-size="11" font-weight="700" font-family="monospace" style="fill:rgb(176, 144, 32);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:11px;font-weight:700;text-anchor:start;dominant-baseline:auto">textureCoordinate</text>
+<text x="598" y="222" fill="#445566" font-size="9" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:start;dominant-baseline:auto">UV</text>
+
+<!-- normal -->
+<rect x="390" y="242" width="240" height="32" rx="4" fill="rgba(80,96,192,0.08)" stroke="rgba(80,96,192,0.25)" stroke-width="0.5" style="fill:rgba(80, 96, 192, 0.08);stroke:rgba(80, 96, 192, 0.25);color:rgb(251, 251, 254);stroke-width:0.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="406" y="262" fill="#5060c0" font-size="11" font-weight="700" font-family="monospace" style="fill:rgb(80, 96, 192);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:11px;font-weight:700;text-anchor:start;dominant-baseline:auto">normal</text>
+<text x="477" y="262" fill="#445566" font-size="9" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:start;dominant-baseline:auto">for CSG</text>
+
+<!-- Right-side annotations -->
+<text x="644" y="98" fill="#334455" font-size="9" font-family="monospace" text-anchor="end" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:end;dominant-baseline:auto">local</text>
+<text x="644" y="138" fill="#334455" font-size="9" font-family="monospace" text-anchor="end" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:end;dominant-baseline:auto">camera</text>
+<text x="644" y="148" fill="#334455" font-size="9" font-family="monospace" text-anchor="end" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:end;dominant-baseline:auto">space</text>
+<text x="644" y="178" fill="#334455" font-size="9" font-family="monospace" text-anchor="end" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:end;dominant-baseline:auto">2D</text>
+<text x="644" y="188" fill="#334455" font-size="9" font-family="monospace" text-anchor="end" style="fill:rgb(51, 68, 85);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:end;dominant-baseline:auto">pixels</text>
+
+<!-- Pipeline arrow -->
+<line x1="654" y1="92" x2="654" y2="270" stroke="rgba(192,80,136,0.15)" stroke-width="1" mask="url(#imagine-text-gaps-eqff6y)" style="fill:rgb(0, 0, 0);stroke:rgba(192, 80, 136, 0.15);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="651.09" y="90" fill="#445566" font-size="8" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:start;dominant-baseline:auto">local</text>
+<text x="651.09" y="270" fill="#445566" font-size="8" font-family="monospace" style="fill:rgb(68, 85, 102);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:start;dominant-baseline:auto">screen</text>
+<polygon points="654,272 650,265 658,265" fill="rgba(192,80,136,0.3)" style="fill:rgba(192, 80, 136, 0.3);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+
+<!-- Summary -->
+<text x="510" y="310" text-anchor="middle" fill="#556677" font-size="10" font-family="monospace" style="fill:rgb(85, 102, 119);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Tracks position across coordinate spaces</text>
+</svg>
diff --git a/doc/Rendering loop/CPU scheduling.png b/doc/Rendering loop/CPU scheduling.png
new file mode 100644 (file)
index 0000000..12aa23d
Binary files /dev/null and b/doc/Rendering loop/CPU scheduling.png differ
diff --git a/doc/Rendering loop/Double buffering.svg b/doc/Rendering loop/Double buffering.svg
new file mode 100644 (file)
index 0000000..141dad6
--- /dev/null
@@ -0,0 +1,47 @@
+<svg viewBox="0 0 520 180" width="520" height="180" xmlns="http://www.w3.org/2000/svg">
+  <rect width="520" height="180" fill="#061018"/>
+
+  <!-- LEFT: Tearing -->
+  <text x="125" y="18" fill="#d04040" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Without double-buffering</text>
+
+  <rect x="25" y="28" width="200" height="140" stroke="rgba(100,100,100,0.4)" stroke-width="1" fill="none" rx="3"/>
+  <text x="125" y="44" fill="#aaa" font-size="8" font-family="monospace" text-anchor="middle">display shows partial update</text>
+
+  <!-- Old frame top half -->
+  <rect x="45" y="52" width="160" height="45" fill="rgba(208,64,64,0.12)" stroke="rgba(208,64,64,0.4)" stroke-width="1"/>
+  <text x="125" y="79" fill="rgba(208,64,64,0.6)" font-size="9" font-family="monospace" text-anchor="middle">old frame</text>
+
+  <!-- Tear line -->
+  <line x1="45" y1="97" x2="205" y2="97" stroke="#d04040" stroke-width="2" stroke-dasharray="4,3"/>
+  <text x="228" y="100" fill="#d04040" font-size="8" font-family="monospace">← tear</text>
+
+  <!-- New frame bottom half -->
+  <rect x="45" y="97" width="160" height="55" fill="rgba(48,160,80,0.1)" stroke="rgba(48,160,80,0.4)" stroke-width="1"/>
+  <text x="125" y="130" fill="rgba(48,160,80,0.6)" font-size="9" font-family="monospace" text-anchor="middle">new frame</text>
+
+  <!-- RIGHT: Double-buffered -->
+  <text x="400" y="18" fill="#30a050" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">With double-buffering</text>
+
+  <!-- Back buffer -->
+  <rect x="295" y="35" width="90" height="130" fill="rgba(32,112,192,0.08)" stroke="rgba(32,112,192,0.5)" stroke-width="1.5" rx="2"/>
+  <text x="340" y="58" fill="#2070c0" font-size="9" font-family="monospace" text-anchor="middle">Back buffer</text>
+  <text x="340" y="72" fill="#aaa" font-size="7" font-family="monospace" text-anchor="middle">(draw here)</text>
+  <!-- Scribble lines to suggest "being drawn" -->
+  <line x1="310" y1="90" x2="365" y2="90" stroke="rgba(32,112,192,0.25)" stroke-width="1"/>
+  <line x1="310" y1="100" x2="355" y2="100" stroke="rgba(32,112,192,0.2)" stroke-width="1"/>
+  <line x1="310" y1="110" x2="345" y2="110" stroke="rgba(32,112,192,0.15)" stroke-width="1"/>
+
+  <!-- Swap arrow -->
+  <line x1="390" y1="100" x2="415" y2="100" stroke="#30a050" stroke-width="1.5"/>
+  <polygon points="415,96 423,100 415,104" fill="#30a050"/>
+  <text x="407" y="90" fill="#30a050" font-size="7" font-family="monospace" text-anchor="middle">swap</text>
+
+  <!-- Front buffer -->
+  <rect x="428" y="35" width="80" height="130" fill="rgba(48,160,80,0.1)" stroke="#30a050" stroke-width="1.5" rx="2"/>
+  <text x="468" y="58" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">Front buffer</text>
+  <text x="468" y="72" fill="#aaa" font-size="7" font-family="monospace" text-anchor="middle">(displayed)</text>
+  <!-- Solid fill to suggest complete frame -->
+  <rect x="440" y="85" width="56" height="65" fill="rgba(48,160,80,0.08)" rx="1"/>
+  <text x="468" y="122" fill="rgba(48,160,80,0.5)" font-size="8" font-family="monospace" text-anchor="middle">complete</text>
+  <text x="468" y="133" fill="rgba(48,160,80,0.5)" font-size="8" font-family="monospace" text-anchor="middle">frame</text>
+</svg>
diff --git a/doc/Rendering loop/Paint tiles.svg b/doc/Rendering loop/Paint tiles.svg
new file mode 100644 (file)
index 0000000..e27d5f8
--- /dev/null
@@ -0,0 +1,34 @@
+<svg viewBox="0 0 400 200" width="400" height="200" xmlns="http://www.w3.org/2000/svg">
+  <rect width="400" height="200" fill="#061018"/>
+
+  <!-- Tile grid: 5 columns x 4 rows = 20 tiles over a 380x160 viewport -->
+  <g fill="#1a3a4a" stroke="#30a050" stroke-width="1">
+    <rect x="10"  y="5" width="76" height="40"/>
+    <rect x="86"  y="5" width="76" height="40"/>
+    <rect x="162" y="5" width="76" height="40"/>
+    <rect x="238" y="5" width="76" height="40"/>
+    <rect x="314" y="5" width="76" height="40"/>
+    <rect x="10"  y="45" width="76" height="40"/>
+    <rect x="86"  y="45" width="76" height="40"/>
+    <rect x="162" y="45" width="76" height="40"/>
+    <rect x="238" y="45" width="76" height="40"/>
+    <rect x="314" y="45" width="76" height="40"/>
+    <rect x="10"  y="85" width="76" height="40"/>
+    <rect x="86"  y="85" width="76" height="40"/>
+    <rect x="162" y="85" width="76" height="40"/>
+    <rect x="238" y="85" width="76" height="40"/>
+    <rect x="314" y="85" width="76" height="40"/>
+    <rect x="10"  y="125" width="76" height="40"/>
+    <rect x="86"  y="125" width="76" height="40"/>
+    <rect x="162" y="125" width="76" height="40"/>
+    <rect x="238" y="125" width="76" height="40"/>
+    <rect x="314" y="125" width="76" height="40"/>
+  </g>
+
+  <!-- A shape overlapping several tiles gets binned into each of them -->
+  <ellipse cx="200" cy="85" rx="90" ry="45" fill="rgba(255,102,0,0.25)" stroke="#FF6600" stroke-width="1.5"/>
+  <text x="200" y="89" fill="#FF6600" font-size="10" font-family="monospace" text-anchor="middle">one shape</text>
+
+  <text x="10" y="182" fill="#30a050" font-size="10" font-family="monospace">~10 tiles per thread; threads steal pending</text>
+  <text x="10" y="195" fill="#30a050" font-size="10" font-family="monospace">tiles — no fixed thread↔tile assignment</text>
+</svg>
diff --git a/doc/Rendering loop/Painter's algorithm.svg b/doc/Rendering loop/Painter's algorithm.svg
new file mode 100644 (file)
index 0000000..7727fc2
--- /dev/null
@@ -0,0 +1,13 @@
+
+<svg viewBox="0 0 520 180" width="520" height="180" xmlns="http://www.w3.org/2000/svg">
+  <rect width="520" height="180" fill="#061018"/>
+  <!-- Far -->
+  <rect x="30" y="15" width="440" height="150" fill="rgba(48,160,80,0.06)" stroke="rgba(48,160,80,0.35)" stroke-width="1.5"/>
+  <text x="250" y="38" fill="rgba(48,160,80,0.7)" font-size="11" font-family="monospace" text-anchor="middle">Far (Z=500) — painted first</text>
+  <!-- Medium -->
+  <rect x="70" y="48" width="360" height="105" fill="rgba(32,112,192,0.10)" stroke="rgba(32,112,192,0.5)" stroke-width="1.5"/>
+  <text x="250" y="80" fill="rgba(32,112,192,0.85)" font-size="11" font-family="monospace" text-anchor="middle">Medium (Z=300) — painted second</text>
+  <!-- Near -->
+  <rect x="115" y="90" width="270" height="55" fill="rgba(200,80,140,0.18)" stroke="#c05088" stroke-width="2"/>
+  <text x="250" y="123" fill="#c05088" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">Near (Z=100) — painted last</text>
+</svg>
diff --git a/doc/Rendering loop/Render pipeline.svg b/doc/Rendering loop/Render pipeline.svg
new file mode 100644 (file)
index 0000000..927e357
--- /dev/null
@@ -0,0 +1,47 @@
+<svg viewBox="0 0 620 80" width="620" height="80" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrowhead" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+  </defs>
+  <rect width="620" height="80" fill="#061018"/>
+
+  <!-- Boxes -->
+  <rect x="8" y="25" width="70" height="30" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="43" y="43" fill="#30a050" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Shapes</text>
+
+  <rect x="96" y="25" width="90" height="30" rx="3" fill="rgba(32,112,192,0.15)" stroke="#2070c0" stroke-width="1.5"/>
+  <text x="141" y="43" fill="#2070c0" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Transform</text>
+
+  <rect x="204" y="25" width="60" height="30" rx="3" fill="rgba(200,80,140,0.15)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="234" y="43" fill="#c05088" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Sort</text>
+
+  <rect x="282" y="25" width="60" height="30" rx="3" fill="rgba(255,170,0,0.15)" stroke="#dd9900" stroke-width="1.5"/>
+  <text x="312" y="43" fill="#dd9900" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Bin</text>
+
+  <rect x="360" y="25" width="70" height="30" rx="3" fill="rgba(255,102,0,0.15)" stroke="#FF6600" stroke-width="1.5"/>
+  <text x="395" y="43" fill="#FF6600" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Paint</text>
+
+  <rect x="448" y="25" width="80" height="30" rx="3" fill="rgba(57,255,20,0.15)" stroke="#39FF14" stroke-width="1.5"/>
+  <text x="488" y="43" fill="#39FF14" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Present</text>
+
+  <rect x="546" y="25" width="66" height="30" rx="3" fill="rgba(100,100,100,0.2)" stroke="#aaa" stroke-width="1.5"/>
+  <text x="579" y="43" fill="#aaa" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Screen</text>
+
+  <!-- Arrows -->
+  <line x1="78" y1="40" x2="92" y2="40" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="186" y1="40" x2="200" y2="40" stroke="#2070c0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="264" y1="40" x2="278" y2="40" stroke="#c05088" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="342" y1="40" x2="356" y2="40" stroke="#dd9900" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="430" y1="40" x2="444" y2="40" stroke="#FF6600" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+  <line x1="528" y1="40" x2="542" y2="40" stroke="#39FF14" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+  <!-- Labels below -->
+  <text x="43" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">3D vertices</text>
+  <text x="141" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">world→screen</text>
+  <text x="234" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">back-to-front</text>
+  <text x="312" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">per tile</text>
+  <text x="395" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">tile grid</text>
+  <text x="488" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">own thread</text>
+</svg>
diff --git a/doc/Rendering loop/index.org b/doc/Rendering loop/index.org
new file mode 100644 (file)
index 0000000..c7fe2e0
--- /dev/null
@@ -0,0 +1,523 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Rendering Loop - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* Rendering loop
+:PROPERTIES:
+:CUSTOM_ID: rendering-loop
+:ID:       a1b2c3d4-e5f6-7890-abcd-ef1234567890
+:END:
+
+The rendering loop is the heart of the engine, continuously generating
+frames on a dedicated background thread. It orchestrates the entire
+rendering pipeline from 3D world space to pixels on screen.
+
+** What is a render loop?
+:PROPERTIES:
+:CUSTOM_ID: what-is-a-render-loop
+:END:
+
+A *render loop* is a continuous process that generates visual frames
+from 3D data. Think of it like a movie camera: each "frame" captures
+the current state of the 3D world and converts it into a 2D image that
+can be displayed on screen.
+
+The process transforms shapes through multiple coordinate systems:
+
+#+INCLUDE: "Render pipeline.svg" export html
+
+Each step has a specific purpose:
+
+| Step      | Input | Output | Purpose |
+|-----------+-------+--------+---------|
+| Shapes    | 3D [[file:../index.org::#vertex][vertices]], [[file:../index.org::#mesh][meshes]] | Scene data | Objects waiting to be drawn |
+| Transform | World coordinates | Screen coordinates | Convert 3D positions to where they appear on screen (see [[file:../index.org::#coordinate-system][coordinate system]]); cull shapes outside the view frustum. Parallel: heavy subtrees fork onto the worker pool |
+| Sort      | Unordered shapes | Ordered by depth | Ensure correct visibility (far objects painted first). Parallel merge sort for large scenes |
+| Bin       | Sorted shapes | Per-tile shape lists | Each paint tile iterates only shapes that can touch it. Parallel over the worker pool |
+| Paint     | Per-tile shape lists | Pixels in buffer | Tiles split the screen into independent work units so clearing and rasterization run in parallel across CPU cores |
+| Present   | Pixel buffer | Screen image | Hand the completed frame to a dedicated thread that copies it to the display |
+
+This pipeline runs repeatedly, targeting 60 frames per second by
+default. Even if nothing moves, the loop continues running—but the
+engine [[#frame-listeners][skips unnecessary work]] when the scene is static.
+
+The steps above describe one frame *logically*, in the order data flows
+through it. In execution the engine is a software pipeline: transform
+of the next frame already runs while the previous frame is still being
+painted, and presentation happens on its own thread. See
+[[#software-pipeline][Software pipeline]].
+
+** Main loop structure
+:PROPERTIES:
+:CUSTOM_ID: main-loop-structure
+:END:
+
+The engine runs two dedicated daemon threads:
+
+- =e3d-render= — produces frames. It runs continuously:
+
+#+BEGIN_SRC java
+while (renderThreadRunning) {
+    ensureThatViewIsUpToDate();  // Produce one frame (or skip)
+    maintainTargetFps();         // Sleep if ahead of schedule
+}
+#+END_SRC
+
+- =e3d-present= — presents frames. It takes completed frames from a
+  mailbox and performs all display-path work (the multi-megabyte
+  =drawImage=, =BufferStrategy.show()= and the X server round-trip), so
+  the render thread never blocks on the display.
+
+Both threads are daemons, so they stop automatically when the JVM
+exits. You can stop them explicitly with [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#stop()][ViewPanel.stop()]].
+
+** Frame rate control
+:PROPERTIES:
+:CUSTOM_ID: frame-rate-control
+:END:
+
+The engine supports two modes:
+
+- *Target FPS mode*: Set with [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#setFrameRate(int)][setFrameRate(int)]].
+  The engine tries to maintain the target rate by sleeping between frames.
+
+  - *When rendering is slower than target*: No sleeping occurs. The engine
+    runs at maximum hardware speed. Missed frames are skipped, not
+    rendered later — the timing simply resets to current time.
+
+  - *When rendering is faster than target*: The thread sleeps to limit FPS
+    to the target rate, avoiding unnecessary CPU usage.
+
+  For example, with a 60 FPS target:
+  - If a complex scene takes 30ms per frame, you get ~33 FPS (hardware limit)
+  - If the scene later simplifies to 10ms per frame, you get exactly
+    60 FPS (throttled by sleeping)
+
+- *Unlimited mode*: Set =setFrameRate(0)= or negative. No sleeping —
+  renders as fast as possible, and frames are produced even when the
+  scene reports no changes, so the measured rate reflects maximum
+  achievable throughput. Useful for benchmarking.
+
+*Production vs presentation.* These are measured separately:
+
+- *Production rate* (=getMeasuredFPS()=) counts frames the pipeline
+  completes per second. This is the benchmark number.
+- *Presentation rate* is how fast frames actually reach the screen. In
+  capped-FPS mode the present thread is paced to 60 blits per second
+  (override with =-Daukio3d.presentRate=N=); the cap exists because the
+  X server also dispatches input, and flooding it with blits causes
+  desktop-wide mouse/keyboard jitter. When production outruns
+  presentation, stale frames are dropped from the mailbox instead of
+  piling up latency. In unlimited (benchmark) mode presentation pacing
+  is disabled entirely, so it cannot throttle production.
+
+* Software pipeline
+:PROPERTIES:
+:CUSTOM_ID: software-pipeline
+:END:
+
+The phases below are described per frame, but consecutive frames
+*overlap*. The engine triple-buffers everything a frame writes:
+
+- 3 framebuffers (each with its own =RenderingContext=)
+- 3 projection buffer slots (per-vertex screen state)
+- 3 render aggregators (transform output, sort/bin state)
+
+A render pass P (one per frame, or one per eye in stereo) transforms
+into slot P mod 3, so it only conflicts with the paint of pass P-3.
+Before each transform the render thread *flushes* completed paint
+passes (mouse hits, frame deposit) and blocks only if paint P-3 is
+still running — which steady-state worker throughput prevents. Workers
+finishing one pass's tiles flow straight into the next pass's queued
+tiles with no idle gap.
+
+Completed frames go to a *presentation mailbox* that keeps only the
+newest frame: if the display path is slower than production, stale
+frames are dropped (and their buffers released) instead of
+accumulating latency — swapchain "mailbox mode".
+
+A per-buffer *present gate* guarantees painting frame F+3 never
+overwrites a buffer the present thread is still blitting frame F from.
+
+The goal of all this overlap is throughput: keep every CPU core busy,
+all the time. No phase waits for another phase of the same frame when
+it could already be working on the next one. The Developer Tools
+thread-activity timeline shows it working — all 18 worker rows packed
+solid with paint, bin and sort tasks from up to three frames at once,
+while the render thread (top row) and present thread tick along above
+them:
+
+#+attr_html: :class responsive-img
+[[file:CPU scheduling.png]]
+
+The pipeline can be disabled with =-Daukio3d.pipeline=false=, restoring
+strictly sequential phase order (each paint pass is awaited
+immediately). This is a kill switch for benchmarking and regression
+hunting.
+
+* Rendering phases
+:PROPERTIES:
+:CUSTOM_ID: rendering-phases
+:END:
+
+Each frame goes through 6 phases. Phases 2–4 run inside an
+asynchronous continuation on the shared worker pool, and phases of
+consecutive frames overlap as described in [[#software-pipeline][Software pipeline]].
+
+** Phase 1: Transform shapes
+:PROPERTIES:
+:CUSTOM_ID: phase-1-transform-shapes
+:END:
+
+All shapes are transformed from world space to screen space:
+
+1. Build camera-relative transform (inverse of camera position/rotation)
+2. Update the view frustum from camera state and viewport dimensions
+3. Walk the scene tree:
+   - Cull composite shapes whose bounding box misses the frustum
+   - Apply camera transform
+   - Project 3D → 2D (perspective projection)
+   - Calculate depth for sorting
+   - Queue for rendering
+
+*What is coordinate transformation?*
+
+Every shape exists in "world space" — its own position in the 3D world.
+To render it, we must convert to "screen space" — where it appears on
+your monitor. This involves:
+
+- *Translation*: Move coordinates relative to camera position
+- *Rotation*: Rotate coordinates based on camera orientation
+- *Projection*: Convert 3D (x, y, z) to 2D (x, y) screen pixels
+
+Objects further away appear smaller (perspective). The [[file:../index.org::#coordinate-system][coordinate system]]
+uses Y-down to match screen conventions, making projection straightforward.
+
+The transform is *parallel and non-blocking*: composites with enough
+children fork their render lists into chunk tasks on the shared worker
+pool (at any nesting level), and the render thread returns without
+waiting. The chunk tasks are drained and merged on a worker thread
+inside the paint continuation, while the render thread is already
+walking the next pass.
+
+*Frustum culling* happens here: composites test their bounding box
+against the frustum and skip invisible subtrees entirely, saving both
+transform and paint work. Per-frame culling statistics are collected
+for the developer tools panel.
+
+** Phase 2: Sort shapes by depth
+:PROPERTIES:
+:CUSTOM_ID: phase-2-sort-shapes
+:END:
+
+Shapes are sorted by depth in descending order (farthest first), with
+the shape id as a deterministic tiebreaker:
+
+#+BEGIN_SRC java
+// ShapesZIndexComparator: descending Z, ties broken by shape id
+if (z1 < z2) return 1;        // z1 is nearer -> sort after z2
+else if (z1 > z2) return -1;  // z1 is farther -> sort before z2
+return Integer.compare(o1.shapeId, o2.shapeId);
+#+END_SRC
+
+Above 8192 queued shapes the sort runs as an instrumented parallel
+merge sort on the shared worker pool; below that it is single-threaded.
+
+*Why sort back-to-front?*
+
+This implements the *painter's algorithm* — like painting a landscape:
+first paint the sky (farthest), then mountains, then trees, then the
+foreground. Each layer covers what's behind it.
+
+#+INCLUDE: "Painter's algorithm.svg" export html
+
+Without sorting, nearby objects might be painted first and then covered
+by distant ones, causing visual errors. This is especially important for
+*transparent objects* — you need to see through the near ones to what's
+behind.
+
+The Z value represents distance from the camera after transformation.
+Larger values = further away. The id tiebreaker keeps the order
+deterministic frame-to-frame, which tiled rendering relies on: every
+tile paints its shapes in the same global (Z, id) order.
+
+** Phase 3: Bin shapes into tiles
+:PROPERTIES:
+:CUSTOM_ID: phase-3-bin-shapes-into-tiles
+:END:
+
+The sorted queue is binned per paint tile by screen-space overlap:
+each tile's bin lists only the shapes whose vertex bounds (plus a
+paint margin) can touch that tile. A shape overlapping several tiles
+is added to each of their bins.
+
+This means a paint thread iterates a short local list instead of the
+whole scene, and it is what makes the tile grid scale: refining the
+grid shrinks each bin instead of just subdividing the clearing work.
+
+Binning is parallelized over the shared worker pool.
+
+** Phase 4: Clear and paint tiles (multi-threaded)
+:PROPERTIES:
+:CUSTOM_ID: phase-4-clear-paint-tiles
+:END:
+
+The viewport is divided into a grid of rectangular *tiles* — roughly
+10 tiles per render thread, split into near-squares (square tiles
+minimize boundary crossings, i.e. how many tiles each shape overlaps).
+These are *not* horizontal bands: each tile has both X and Y bounds.
+
+#+INCLUDE: "Paint tiles.svg" export html
+
+Painting is work-stolen, not pre-assigned. All tile tasks go onto a
+shared =ForkJoinPool= (sized to 75% of CPU threads by default, at most
+cores − 1, so one thread stays free for the rest of the system;
+changeable at runtime via =setNumRenderThreads(int)=). A worker that
+finishes a cheap tile immediately pulls the next queued task — another
+tile (of this or an adjacent frame's pass), a transform chunk, a sort
+piece — so cores never idle behind a busy thread.
+
+Each tile task:
+
+1. *Clear tile*: fill its rectangle with background color
+2. *Paint shapes*: rasterize the tile's bin, back-to-front, clipping
+   at tile bounds
+
+Both operations happen within the same task, so clearing always
+completes before painting on that tile. Parallel clearing across
+disjoint tiles maximizes memory bandwidth utilization.
+
+Each tile renders through a =SegmentRenderingContext= — a view of the
+frame context carrying the tile's X/Y bounds and a =Graphics2D=
+pre-clipped to the tile rectangle for thread-safe text and
+anti-aliased drawing. (The class name predates the tile grid; a
+"segment" is now a tile.) Mouse hit detection happens during painting,
+before clipping.
+
+A =CountDownLatch= tracks completion of all the pass's tiles — but the
+render thread does *not* wait for it here. The latch is awaited one
+pass later, during the flush (see [[#phase-5-flush-completed-passes][Phase 5]]).
+
+** Phase 5: Flush completed passes
+:PROPERTIES:
+:CUSTOM_ID: phase-5-flush-completed-passes
+:END:
+
+Before each new transform, the render thread flushes paint passes that
+have completed. For each flushed pass:
+
+1. Await its tile latch (blocks only when correctness demands it —
+   transform of pass P may not start before paint of pass P-3 finished)
+2. *Combine mouse results*: during painting, each tile tracked which
+   shape is under the mouse cursor. Since all tiles paint the same
+   back-to-front order, they should all report the same hit; the first
+   non-null result wins:
+
+#+BEGIN_SRC java
+for (SegmentRenderingContext ctx : segmentContexts) {
+    if (ctx.getSegmentMouseHit() != null) {
+        context.setCurrentObjectUnderMouseCursor(ctx.getSegmentMouseHit());
+        return;
+    }
+}
+#+END_SRC
+
+   In stereo mode this only runs for the eye whose viewport actually
+   contains the cursor — each eye sees a different camera position, so
+   combining for the wrong eye would overwrite a valid hit with null.
+3. If this pass completed a frame, deposit the frame into the
+   presentation mailbox (see [[#software-pipeline][Software pipeline]]). The render
+   thread never blocks on the display.
+
+Passes that finished painting are flushed without any blocking, so
+completed frames reach the mailbox as early as possible.
+
+** Phase 6: Present frame
+:PROPERTIES:
+:CUSTOM_ID: phase-6-present-frame
+:END:
+
+The =e3d-present= thread takes the newest mailbox frame (dropping any
+unshown older frame) and copies its =BufferedImage= to the screen using
+[[https://cr.openjdk.org/~iris/se/17/latestSpec/api/java.desktop/java/awt/image/BufferStrategy.html][BufferStrategy]] for tear-free page-flipping:
+
+#+BEGIN_SRC java
+do {
+    Graphics2D g = bufferStrategy.getDrawGraphics();
+    g.drawImage(context.bufferedImage, 0, 0, null);
+    g.dispose();
+} while (bufferStrategy.contentsRestored());
+
+// framebuffer released for reuse here
+bufferStrategy.show();
+Toolkit.getDefaultToolkit().sync();
+#+END_SRC
+
+The frame's buffer is released for reuse right after the =drawImage=
+loop — =show()= and =sync()= touch only the BufferStrategy's own back
+buffer and the X connection, and at high resolutions they cost more
+than the draw itself, so the next frame's painters don't wait for them.
+
+*What is double-buffering?*
+
+Without double-buffering, the screen updates while pixels are being
+written. This causes *screen tearing* — visible horizontal splits where
+the top of the frame shows old content while the bottom shows new.
+
+#+INCLUDE: "Double buffering.svg" export html
+
+Double-buffering uses two pixel buffers:
+- *Back buffer*: Where rendering happens (offscreen, invisible)
+- *Front buffer*: What's currently displayed on screen
+
+When rendering completes, the buffers *swap* in one atomic operation.
+The viewer always sees complete frames, never partial updates.
+
+The =do-while= loop handles the case where the OS recreates the back
+buffer (common during window resizing). Since our offscreen
+=BufferedImage= still has the correct pixels, we only need to re-blit,
+not re-render.
+
+* Frame listeners and smart repaint skipping
+:PROPERTIES:
+:CUSTOM_ID: frame-listeners
+:ID:       e360a877-cca6-4cba-a9a4-ea40b0f1a183
+:END:
+
+A *FrameListener* is a callback that runs custom logic before each potential
+frame. Think of it as your "per-frame hook" — the engine calls all registered
+listeners, giving them a chance to update animations, physics, or game logic.
+
+** Registering a frame listener
+
+Use [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#addFrameListener(eu.svjatoslav.aukio.e3d.gui.FrameListener)][addFrameListener()]] to register your callback:
+
+#+BEGIN_SRC java
+// This is how you register a frame listener
+viewPanel.addFrameListener((panel, deltaMs) -> {
+    // Example: simple animation listener
+    double rotationSpeed = 1.0;  // radians per second
+    shape.rotate(rotationSpeed * deltaMs / 1000.0);  // Framerate-independent rotation
+    return true;  // Request repaint (shape moved)
+});
+#+END_SRC
+
+The listener receives two parameters:
+- =panel=: The ViewPanel that's rendering
+- =deltaMs=: Milliseconds since last frame (for framerate-independent animation)
+
+The return value controls whether the frame gets rendered:
+- =true=: "Something changed — repaint the screen"
+- =false=: "Nothing changed — can skip this frame"
+
+** Frame skipping optimization
+
+The engine avoids unnecessary rendering. A frame is skipped when:
+- *All listeners return false* (nothing changed in your scene)
+- *Camera did not move* (built-in Camera listener returns false once
+  the camera comes to rest)
+- *No resize or repaint requests*
+
+This means a static scene with no animations consumes almost zero CPU.
+The render thread keeps running (checking for changes), but actual pixel
+rendering is skipped entirely. Skipped frames still flush any pending
+paint passes from earlier frames, so in-flight frames always reach the
+screen.
+
+Two exceptions force a frame regardless of listeners:
+- *Unlimited (benchmark) mode* (=targetFPS <= 0=) renders continuously,
+  so the measured rate reflects maximum throughput
+- An explicit repaint request (resize, stereo toggle,
+  =repaintDuringNextViewUpdate()=, etc.)
+
+#+BEGIN_SRC java
+// Example: listener that only requests repaint when needed
+viewPanel.addFrameListener((panel, deltaMs) -> {
+    if (gameState.hasUpdates()) {
+        gameState.processUpdates();
+        return true;   // Only repaint when game state actually changed
+    }
+    return false;      // Skip frame — nothing to update
+});
+#+END_SRC
+
+** Built-in listeners
+
+The engine registers these listeners by default:
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/Camera.html][Camera]] — applies movement velocity and friction each frame, and
+  returns true when the camera actually moved (more than a small
+  threshold), i.e. while the user is actively navigating
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.html][InputManager]] — processes mouse/keyboard events
+
+When the camera stops moving and you release all keys, the Camera listener
+returns false. If your custom listeners also return false, the frame is
+skipped until something changes.
+
+* Rendering context
+:PROPERTIES:
+:CUSTOM_ID: rendering-context
+:END:
+
+The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]] holds all state for rendering into one framebuffer:
+the pixel buffer, projection parameters, and per-frame bookkeeping.
+
+| Field | Purpose |
+|-------+---------|
+| =pixels[]= | Raw pixel buffer (int[] in RGB format) |
+| =bufferedImage= | Java2D wrapper around pixels |
+| =graphics= | Graphics2D for text, lines, shapes |
+| =width=, =height= | Full framebuffer dimensions |
+| =centerCoordinate= | Screen center of the active viewport (for projection) |
+| =projectionScale= | Perspective scale factor, derived from viewport width. Mutable: each stereo eye sets its own |
+| =renderMinX=, =renderMaxX= | X bounds of the active viewport or tile |
+| =renderMinY=, =renderMaxY= | Y bounds (full height on the frame context, tile bounds on segment views) |
+| =stereoEye=, =stereoViewportWidth=, =stereoViewportOffsetX= | Which eye this pass renders and where its viewport sits in the buffer |
+| =tilesX=, =tilesY=, =viewportCount=, =numRenderSegments= | Tile grid geometry (segments = tilesX × tilesY × viewports) |
+| =frustum= | View frustum for culling, rebuilt each pass from camera state |
+| =frameNumber= | Per-context frame counter |
+| =transformCycleId= | Globally unique transform-cycle id, safe key for per-cycle memoization |
+| =vertexSlot= | Projection buffer slot (0–2) this pass transforms into |
+
+** Triple-buffered frame contexts
+
+The engine keeps /three/ frame contexts, cycled by frame parity. While
+frame N is still being painted from one buffer, frame N+1 already
+transforms into the next — paint threads never idle waiting for the
+transform phase, and vice versa. A per-buffer *present gate* prevents
+painting frame F+3 into a buffer the present thread is still blitting
+frame F from.
+
+All three contexts are recreated together when the window is resized,
+when the tile grid changes (render thread count), or when stereo mode
+is toggled. Otherwise they are reused — =prepareForNewFrameRendering()=
+just resets per-frame state like mouse tracking.
+
+** Per-pass copies
+
+Each render pass (one per eye in stereo) works on a private /copy/ of
+the frame context. The copy shares the pixel buffer, graphics and
+services, but owns the projection fields (center, scale, viewport,
+vertex slot), so the next pass's setup cannot disturb a pass whose
+transform or paint is still in flight.
+
+Consequence for engine code: per-frame mutable state must be allocated
+eagerly on the frame context. Anything created lazily inside a pass
+lands on the throwaway copy and is lost.
+
+** Tile segment views
+
+Each paint tile gets a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.html][SegmentRenderingContext]],
+a view that shares the framebuffer with its parent but carries its own
+X/Y tile bounds and a pre-clipped =Graphics2D= for thread-safe text and
+shape drawing. Mouse hits are tracked per tile and combined after all
+tiles finish painting.
diff --git a/doc/SDF textures/SDF concept.svg b/doc/SDF textures/SDF concept.svg
new file mode 100644 (file)
index 0000000..bc1b750
--- /dev/null
@@ -0,0 +1,81 @@
+<svg viewBox="0 0 640 430" width="640" height="430" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+
+  <rect width="640" height="430" fill="#061018"/>
+
+  <text x="320" y="32" fill="#40b0d0" font-size="16" font-family="monospace" text-anchor="middle" filter="url(#glow)">Why a distance field, not a bitmap</text>
+  <text x="320" y="50" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">one glyph edge, magnified 8x - stored coverage vs re-derived coverage</text>
+
+  <!-- LEFT: stored bitmap coverage -->
+  <text x="160" y="86" fill="#FF8833" font-size="12" font-family="monospace" text-anchor="middle" filter="url(#glow)">BITMAP coverage</text>
+  <text x="160" y="101" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">what you store is what you get</text>
+
+  <!-- blocky stair edge -->
+  <g stroke="none">
+    <rect x="60"  y="120" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="100" y="120" width="40" height="40" fill="#39FF14" opacity="0.5"/>
+    <rect x="60"  y="160" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="100" y="160" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="140" y="160" width="40" height="40" fill="#39FF14" opacity="0.4"/>
+    <rect x="60"  y="200" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="100" y="200" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="140" y="200" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="180" y="200" width="40" height="40" fill="#39FF14" opacity="0.3"/>
+    <rect x="60"  y="240" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="100" y="240" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="140" y="240" width="40" height="40" fill="#39FF14" opacity="0.85"/>
+    <rect x="180" y="240" width="40" height="40" fill="#39FF14" opacity="0.7"/>
+  </g>
+  <g stroke="#1a3a4a" stroke-width="1" fill="none">
+    <rect x="60" y="120" width="200" height="160"/>
+    <line x1="100" y1="120" x2="100" y2="280"/>
+    <line x1="140" y1="120" x2="140" y2="280"/>
+    <line x1="180" y1="120" x2="180" y2="280"/>
+    <line x1="220" y1="120" x2="220" y2="280"/>
+    <line x1="60" y1="160" x2="260" y2="160"/>
+    <line x1="60" y1="200" x2="260" y2="200"/>
+    <line x1="60" y1="240" x2="260" y2="240"/>
+  </g>
+  <text x="160" y="305" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">texel grid IS the resolution limit:</text>
+  <text x="160" y="318" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">edges stair-step, curves become blocks</text>
+
+  <!-- RIGHT: distance field -->
+  <text x="480" y="86" fill="#39FF14" font-size="12" font-family="monospace" text-anchor="middle" filter="url(#glow)">SDF: distance to edge</text>
+  <text x="480" y="101" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">a smooth field - the edge is re-derived per pixel</text>
+
+  <!-- smooth edge line through a gradient field -->
+  <defs>
+    <linearGradient id="sdfGrad" x1="0" y1="0" x2="1" y2="1">
+      <stop offset="0" stop-color="#39FF14" stop-opacity="0.85"/>
+      <stop offset="0.42" stop-color="#39FF14" stop-opacity="0.55"/>
+      <stop offset="0.55" stop-color="#39FF14" stop-opacity="0.25"/>
+      <stop offset="0.7" stop-color="#39FF14" stop-opacity="0.06"/>
+      <stop offset="1" stop-color="#39FF14" stop-opacity="0"/>
+    </linearGradient>
+  </defs>
+  <rect x="380" y="120" width="200" height="160" fill="url(#sdfGrad)"/>
+  <rect x="380" y="120" width="200" height="160" fill="none" stroke="#1a3a4a" stroke-width="1"/>
+  <!-- the re-derived edge: crisp at any zoom -->
+  <line x1="420" y1="280" x2="530" y2="120" stroke="#40b0d0" stroke-width="2" filter="url(#glow)"/>
+  <text x="500" y="270" fill="#40b0d0" font-size="9" font-family="monospace">edge recovered at</text>
+  <text x="500" y="282" fill="#40b0d0" font-size="9" font-family="monospace">display resolution</text>
+
+  <!-- distance annotations -->
+  <text x="410" y="150" fill="#999" font-size="9" font-family="monospace">d &lt; 0: inside ink</text>
+  <text x="520" y="140" fill="#999" font-size="9" font-family="monospace">d &gt; 0: outside</text>
+  <text x="455" y="205" fill="#40b0d0" font-size="9" font-family="monospace" transform="rotate(-52 455 205)">d = 0: the edge</text>
+
+  <!-- bottom takeaway -->
+  <rect x="40" y="340" width="560" height="64" rx="6" fill="rgba(64,176,208,0.06)" stroke="#40b0d0" stroke-width="1"/>
+  <text x="320" y="364" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">coverage = (127.5 - d) * aaK + 128</text>
+  <text x="320" y="382" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">aaK scales the gradient window to the pixel footprint - the same 16x32 texel field</text>
+  <text x="320" y="395" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">serves a 4-pixel label and a full-screen billboard</text>
+</svg>
diff --git a/doc/SDF textures/SDF glyph pipeline.svg b/doc/SDF textures/SDF glyph pipeline.svg
new file mode 100644 (file)
index 0000000..09473d6
--- /dev/null
@@ -0,0 +1,88 @@
+<svg viewBox="0 0 640 470" width="640" height="470" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+    </marker>
+  </defs>
+
+  <rect width="640" height="470" fill="#061018"/>
+
+  <text x="320" y="30" fill="#40b0d0" font-size="16" font-family="monospace" text-anchor="middle" filter="url(#glow)">Glyph field generation (SdfGlyphCache)</text>
+  <text x="320" y="48" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">once per character, then cached - stamping is a block copy</text>
+
+  <!-- step 1: hi-res rasterize -->
+  <rect x="40" y="70" width="170" height="84" rx="6" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5"/>
+  <text x="125" y="90" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">1. rasterize glyph</text>
+  <text x="125" y="106" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">Liberation Mono Bold, AA on</text>
+  <text x="125" y="119" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">64x128 px (4x supersample)</text>
+  <text x="125" y="132" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">font auto-sized to fit cell</text>
+  <text x="125" y="148" fill="#c05088" font-size="9" font-family="monospace" text-anchor="middle">advance 0.6em (Courier-compat)</text>
+
+  <line x1="210" y1="112" x2="240" y2="112" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+
+  <!-- step 2: EDT -->
+  <rect x="245" y="70" width="170" height="84" rx="6" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+  <text x="330" y="90" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">2. distance transform</text>
+  <text x="330" y="106" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">exact Euclidean EDT</text>
+  <text x="330" y="119" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">(Felzenszwalb-Huttenlocher,</text>
+  <text x="330" y="132" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">two separable 1-D passes)</text>
+  <text x="330" y="148" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">dOut to ink, dIn to background</text>
+
+  <line x1="415" y1="112" x2="445" y2="112" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+
+  <!-- step 3: signed + clamp + downsample -->
+  <rect x="450" y="70" width="160" height="96" rx="6" fill="rgba(255,136,51,0.07)" stroke="#FF8833" stroke-width="1.5"/>
+  <text x="530" y="88" fill="#FF8833" font-size="11" font-family="monospace" text-anchor="middle">3. sign, clamp, average</text>
+  <text x="530" y="104" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">signed = dOut - dIn</text>
+  <text x="530" y="117" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">clamp to +/- 2 texels spread</text>
+  <text x="530" y="130" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">average FIELD down to 16x32</text>
+  <text x="530" y="148" fill="#c05088" font-size="9" font-family="monospace" text-anchor="middle">(averaging the field, not coverage,</text>
+  <text x="530" y="160" fill="#c05088" font-size="9" font-family="monospace" text-anchor="middle">preserves the edge position)</text>
+
+  <!-- down to mask encoding -->
+  <line x1="530" y1="180" x2="530" y2="196" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+
+  <!-- encoding bar -->
+  <rect x="120" y="200" width="420" height="54" rx="6" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="330" y="220" fill="#c05088" font-size="11" font-family="monospace" text-anchor="middle">mask encoding (per texel, 0..255)</text>
+  <text x="330" y="236" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">0 = deep inside ink    127.5 = the edge    255 = far outside</text>
+
+  <!-- gradient strip -->
+  <defs>
+    <linearGradient id="maskBar" x1="0" y1="0" x2="1" y2="0">
+      <stop offset="0" stop-color="#000000"/>
+      <stop offset="0.5" stop-color="#808080"/>
+      <stop offset="1" stop-color="#ffffff"/>
+    </linearGradient>
+  </defs>
+  <rect x="170" y="242" width="320" height="8" fill="url(#maskBar)"/>
+
+  <!-- consumers -->
+  <line x1="240" y1="254" x2="240" y2="286" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+  <line x1="430" y1="254" x2="430" y2="286" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr)"/>
+
+  <rect x="120" y="290" width="240" height="66" rx="6" fill="rgba(57,255,20,0.06)" stroke="#39FF14" stroke-width="1"/>
+  <text x="240" y="310" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">TextCanvas.putChar</text>
+  <text x="240" y="326" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">stamps the cached 16x32 mask</text>
+  <text x="240" y="339" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">into the cell position of sdfMask</text>
+
+  <rect x="380" y="290" width="190" height="66" rx="6" fill="rgba(32,112,192,0.07)" stroke="#2070c0" stroke-width="1"/>
+  <text x="475" y="310" fill="#2070c0" font-size="11" font-family="monospace" text-anchor="middle">three texture layers</text>
+  <text x="475" y="326" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">sdfMask: glyph SHAPES (bilinear)</text>
+  <text x="475" y="339" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">sdfForeground + primary: colors</text>
+
+  <text x="60" y="392" fill="#999" font-size="9" font-family="monospace">why sans-serif bold: Courier's serifs and hairline strokes decay into unresolvable noise</text>
+  <text x="60" y="405" fill="#999" font-size="9" font-family="monospace">when the distance field is minified - uniform sturdy strokes survive</text>
+
+  <!-- why not mipmap note -->
+  <rect x="60" y="418" width="520" height="40" rx="6" fill="rgba(255,68,68,0.05)" stroke="#FF4444" stroke-width="1"/>
+  <text x="320" y="434" fill="#FF4444" font-size="10" font-family="monospace" text-anchor="middle">NO mipmaps on the mask: the edge gradient spans ~2 texels,</text>
+  <text x="320" y="448" fill="#FF4444" font-size="10" font-family="monospace" text-anchor="middle">half-res masks melt glyph edges - minification is analytic instead</text>
+</svg>
diff --git a/doc/SDF textures/SDF minification.svg b/doc/SDF textures/SDF minification.svg
new file mode 100644 (file)
index 0000000..95cce51
--- /dev/null
@@ -0,0 +1,71 @@
+<svg viewBox="0 0 640 420" width="640" height="420" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arr2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+    </marker>
+  </defs>
+
+  <rect width="640" height="420" fill="#061018"/>
+
+  <text x="320" y="30" fill="#40b0d0" font-size="16" font-family="monospace" text-anchor="middle" filter="url(#glow)">Minification: analytic coverage window</text>
+  <text x="320" y="48" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">one screen pixel covering many texels still resolves the edge correctly</text>
+
+  <!-- screen pixel footprint diagram -->
+  <rect x="50" y="70" width="270" height="215" rx="6" fill="rgba(57,255,20,0.05)" stroke="#39FF14" stroke-width="1"/>
+  <text x="185" y="90" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">a screen pixel on the texture</text>
+
+  <!-- texel grid 6x5 -->
+  <g stroke="#1a3a4a" stroke-width="1">
+    <rect x="70" y="105" width="180" height="125"/>
+    <line x1="100" y1="105" x2="100" y2="230"/>
+    <line x1="130" y1="105" x2="130" y2="230"/>
+    <line x1="160" y1="105" x2="160" y2="230"/>
+    <line x1="190" y1="105" x2="190" y2="230"/>
+    <line x1="220" y1="105" x2="220" y2="230"/>
+    <line x1="70" y1="130" x2="250" y2="130"/>
+    <line x1="70" y1="155" x2="250" y2="155"/>
+    <line x1="70" y1="180" x2="250" y2="180"/>
+    <line x1="70" y1="205" x2="250" y2="205"/>
+  </g>
+  <!-- glyph edge crossing the grid -->
+  <line x1="90" y1="230" x2="230" y2="105" stroke="#c05088" stroke-width="2"/>
+  <!-- pixel footprint box -->
+  <rect x="115" y="130" width="90" height="75" fill="rgba(255,136,51,0.10)" stroke="#FF8833" stroke-width="2" stroke-dasharray="5 3"/>
+  <text x="160" y="245" fill="#FF8833" font-size="9" font-family="monospace" text-anchor="middle">footprint: many texels per pixel</text>
+  <text x="185" y="262" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">a bitmap would average to mush or alias;</text>
+  <text x="185" y="275" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">the field still knows where the edge is</text>
+
+  <!-- formula flow -->
+  <rect x="350" y="70" width="250" height="60" rx="6" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+  <text x="475" y="92" fill="#40b0d0" font-size="10" font-family="monospace" text-anchor="middle">per-axis footprint from UV gradients</text>
+  <text x="475" y="106" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">footX = |dUV/dx|, footY = |dUV/dy|</text>
+  <text x="475" y="119" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">window follows the SHARPEST axis</text>
+
+  <line x1="475" y1="130" x2="475" y2="150" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr2)"/>
+
+  <rect x="350" y="155" width="250" height="44" rx="6" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="475" y="173" fill="#c05088" font-size="10" font-family="monospace" text-anchor="middle">aaK = 2*spread / texelsPerPixel</text>
+  <text x="475" y="188" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">widens the coverage window as pixels grow</text>
+
+  <line x1="475" y1="199" x2="475" y2="219" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arr2)"/>
+
+  <rect x="350" y="224" width="250" height="66" rx="6" fill="rgba(255,136,51,0.07)" stroke="#FF8833" stroke-width="1.5"/>
+  <text x="475" y="242" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle">perceptual corrections (minified text</text>
+  <text x="475" y="254" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle">else reads as gray haze):</text>
+  <text x="475" y="270" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">SHARPEN x2: sub-pixel window kills halo</text>
+  <text x="475" y="283" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">coverage gamma &lt; 1: stem darkening</text>
+
+  <!-- result strip -->
+  <rect x="60" y="310" width="520" height="90" rx="6" fill="rgba(57,255,20,0.05)" stroke="#39FF14" stroke-width="1"/>
+  <text x="320" y="332" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">result: graceful degradation</text>
+  <text x="320" y="350" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">magnified: edges re-derived at display resolution - razor sharp</text>
+  <text x="320" y="365" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">minified: coverage fades smoothly to clean gray, no crawling aliases</text>
+  <text x="320" y="380" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">angled: the uncompressed axis keeps its sharpness</text>
+</svg>
diff --git a/doc/SDF textures/glyph-sdf-S.png b/doc/SDF textures/glyph-sdf-S.png
new file mode 100644 (file)
index 0000000..ecc1de5
Binary files /dev/null and b/doc/SDF textures/glyph-sdf-S.png differ
diff --git a/doc/SDF textures/index.org b/doc/SDF textures/index.org
new file mode 100644 (file)
index 0000000..ff4cf78
--- /dev/null
@@ -0,0 +1,252 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: SDF Textures - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* What SDF textures are
+:PROPERTIES:
+:CUSTOM_ID: what-sdf-is
+:END:
+
+A regular texture stores *coverage*: each texel says "this much ink
+here". That is a photocopy of the glyph — resample it (magnify, minify,
+view at an angle) and the stored pixels blur or alias, because the
+information about /where the edge is/ was thrown away when the glyph
+was rasterized.
+
+A *signed distance field* (SDF) texture stores something smarter: per
+texel, the *distance to the nearest edge* — negative inside the ink,
+positive outside, zero exactly on the boundary. The rasterizer then
+re-derives coverage per screen pixel from this smooth field. The edge
+position survives resampling because the field around it is linear —
+bilinear interpolation of a linear ramp is exact.
+
+#+ATTR_HTML: :width 640
+[[file:SDF concept.svg]]
+
+In Aukio 3D the mask is a grayscale field in =texture.sdfMask=:
+
+- =0= — deep inside the ink
+- =127.5= — exactly on the edge
+- =255= — far outside any glyph
+
+The gradient spans only =SPREAD_TEXELS = 2.0= texels around the edge —
+that narrow band is all the rasterizer needs.
+
+Here is a real field, dumped straight from =SdfGlyphCache= (glyph "S",
+16x32 texels, upscaled 12x with nearest so you can see the texels):
+
+[[file:glyph-sdf-S.png]]
+
+Dark inside the strokes, bright outside, and a smooth gray ramp exactly
+two texels wide around the contour.
+
+* Generating glyph fields
+:PROPERTIES:
+:CUSTOM_ID: glyph-pipeline
+:END:
+
+=SdfGlyphCache= generates each character's distance field once and
+caches it in a =ConcurrentHashMap=; stamping a glyph into a canvas is
+then just a block copy.
+
+#+ATTR_HTML: :width 640
+[[file:SDF glyph pipeline.svg]]
+
+The steps:
+
+1. *Rasterize* the glyph with AWT at 4x the cell size (64x128 pixels)
+   with anti-aliasing on, using Liberation Mono Bold (metric-compatible
+   with Courier New, so the cell grid is unchanged). The font size is
+   auto-shrunk until the widest glyph fits the scratch without clipping
+   — a clipped glyph would corrupt the distance field at the cell edge.
+2. *Distance transform*: an exact Euclidean distance transform
+   (Felzenszwalb & Huttenlocher, two separable 1-D passes over parabola
+   envelopes) is run twice — once for distance to nearest ink pixel,
+   once for distance to nearest background pixel.
+3. *Sign, clamp, average*: signed distance = dOut - dIn, clamped to
+   +/-2 texels of spread, then the *field* (not coverage) is averaged
+   down to the 16x32 cell resolution. Averaging the field preserves the
+   edge position; averaging coverage would not.
+
+The font choice matters: Courier's serifs and hairline strokes decay
+into unresolvable noise when the field is minified. A uniform-stroke
+bold sans-serif survives.
+
+* The rendering path
+:PROPERTIES:
+:CUSTOM_ID: render-path
+:END:
+
+When =texture.isSdf()= is true (an =sdfMask= is attached),
+=TexturedTriangle.paintSdf= takes over. Three layers are involved:
+
+| Layer            | Contents                    | Sampling |
+|------------------+-----------------------------+----------|
+| =sdfMask=        | glyph shapes (the field)    | bilinear |
+| =sdfForeground=  | ink color, flat per cell    | nearest  |
+| =primaryBitmap=  | background color, per cell  | nearest  |
+
+Per screen pixel:
+
+1. Sample the mask bilinearly (fixed-point) -> distance =d=.
+2. Convert to coverage: =cov = (127.5 - d) * aaK + 128=, clamped to
+   [0, 256]. =aaK= scales the 2-texel gradient window to the current
+   pixel footprint (see next section).
+3. Blend: =pixel = bg * (1 - cov) + fg * cov=.
+
+Perspective-correct interpolation applies to SDF triangles exactly as
+it does to regular textured triangles — same affine-sufficiency test,
+same subdivided correction. See
+[[file:../Perspective correct textures/index.org][Perspective-correct
+textures]]; only the per-pixel sampling differs.
+
+* Minification without mipmaps
+:PROPERTIES:
+:CUSTOM_ID: minification
+:END:
+
+*There is deliberately no mipmap chain for SDF layers.* A distance
+field's edge gradient spans ~2 texels; a half-resolution mask melts the
+glyph edges. Worse, the two triangles of a rectangle cross mip
+thresholds at slightly different distances, producing a hard diagonal
+quality split and sudden blur steps while dollying (observed in
+practice).
+
+Minification is instead handled *analytically*: the coverage window is
+widened by the screen-space pixel footprint, giving area-correct
+coverage straight from the primary field.
+
+#+ATTR_HTML: :width 640
+[[file:SDF minification.svg]]
+
+The footprint is computed per axis from the screen-space UV gradients —
+=text on an angled plane is minified mostly along one axis=, and an
+isotropic average would blur the axis that still has resolution to
+spare. The coverage window follows the sharpest axis.
+
+Area-correct coverage alone reads as a low-contrast gray haze, so two
+perceptual corrections (A/B-tuned on far + angled text) kick in under
+minification:
+
+- *Sharpening* (=SDF_SHARPEN=, default 2): narrows the coverage window
+  below one pixel — kills the haze halo at the cost of slight shimmer.
+- *Coverage gamma* (< 1, automatic from the footprint): darkens stems
+  like a small-size font rasterizer, keeping thin strokes present.
+
+Real output, rendered headlessly through the [[file:../index.org::#snapshot][Snapshot tool]]:
+
+Magnified — edges re-derived at display resolution, razor sharp:
+
+[[file:sdf-near.png]]
+
+At moderate distance:
+
+[[file:sdf-mid.png]]
+
+Far away — small but clean, fading to gray instead of disintegrating
+into aliases (right: 4x nearest zoom of the center):
+
+[[file:sdf-far.png]]
+
+[[file:sdf-far-zoom.png]]
+
+At an oblique angle — foreshortened along one axis, still sharp along
+the other:
+
+[[file:sdf-angled.png]]
+
+* Using it
+:PROPERTIES:
+:CUSTOM_ID: using-sdf
+:END:
+
+*TextCanvas* is the main entry point: a textured rectangle carrying a
+character grid in 3D space. World cell size 8x16 units, texture cell
+16x32 texels (2 texels per world unit).
+
+#+BEGIN_SRC java
+Transform location = new Transform(new Point3D(0, 0, 500));
+TextCanvas canvas = new TextCanvas(location, "Hello, World!",
+        Color.WHITE, Color.BLACK);
+shapeCollection.addShape(canvas);
+
+// blank canvas + cursor writing
+TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40),
+        Color.GREEN, Color.BLACK);
+blank.locate(0, 0);
+blank.print("Line 1");
+blank.locate(1, 0);
+blank.print("Line 2");
+blank.setForegroundColor(Color.RED);   // affects subsequent writes
+blank.setTextColor(Color.CYAN);        // recolors existing ink only
+#+END_SRC
+
+Colors are per-cell: each =putChar= fills the cell's rectangle in the
+background and foreground layers, so one canvas can hold many colors.
+
+*ForwardOrientedTextBlock* renders the same pipeline onto a billboard
+that always faces the camera — for labels that must stay readable from
+any angle:
+
+#+BEGIN_SRC java
+ForwardOrientedTextBlock label = new ForwardOrientedTextBlock(
+        new Point3D(0, -50, 300), 1.0, 2, "Hello, World!", Color.RED);
+shapeCollection.addShape(label);
+#+END_SRC
+
+Real use in the demos: the life demo's help panel (=life_demo/Main.java=
+=createHelpPanel()=) and the axis labels in =CoordinateSystemDemo=.
+
+* Tuning knobs
+:PROPERTIES:
+:CUSTOM_ID: tuning
+:END:
+
+JVM properties (A/B tuning knobs in =TexturedTriangle=):
+
+| Property          | Default | Effect                                    |
+|-------------------+---------+-------------------------------------------|
+| =e3d.sdf.gamma=   | 0 (auto) | fixed coverage gamma; auto derives from footprint |
+| =e3d.sdf.sharpen= | 2       | coverage window narrowing; 1 = pixel-exact |
+| =e3d.sdf.debug=   | false   | prints per-triangle footprints and path decisions to stderr |
+
+* Limitations
+:PROPERTIES:
+:CUSTOM_ID: limitations
+:END:
+
+- *Fixed cell grid*: TextCanvas is monospace by construction (16x32
+  texel cells). Proportional fonts would need a different stamping
+  scheme.
+- *ASCII-oriented cache*: =SdfGlyphCache= measures printable ASCII
+  (33..126) when sizing the font; exotic glyphs may fit worse.
+- *Under extreme minification* text fades to gray by design — that is
+  the correct physical answer (a sub-pixel glyph has no shape left),
+  but it means distant labels are decorative, not readable.
+- *Bandwidth under minification*: sampling the primary field (no mip
+  chain) costs more bandwidth per pixel. Text surfaces are small, so
+  this is the right trade — do not attach SDF masks to huge surfaces.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                       | Role                                             |
+|-----------------------------+--------------------------------------------------|
+| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.html][TextCanvas]]                  | Character grid surface in 3D; owns the layers    |
+| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.html][SdfGlyphCache]]               | Per-glyph field generation + cache (EDT inside)  |
+| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.html][ForwardOrientedTextBlock]]    | Camera-facing text billboard                     |
+| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]]            | =paintSdf= — the scanline path                   |
+| [[file:../apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.html][Texture]]                     | =sdfMask=, =sdfForeground=, =sdfSpreadTexels=    |
+
+*See also:*
+
+- [[file:../Perspective correct textures/][Perspective-correct textures]] — the scanline texture-mapping path
+  that SDF rendering builds on; both paths live in =TexturedTriangle=
+  and share the same interpolated UVs.
+
diff --git a/doc/SDF textures/sdf-angled.png b/doc/SDF textures/sdf-angled.png
new file mode 100644 (file)
index 0000000..34814bf
Binary files /dev/null and b/doc/SDF textures/sdf-angled.png differ
diff --git a/doc/SDF textures/sdf-far-zoom.png b/doc/SDF textures/sdf-far-zoom.png
new file mode 100644 (file)
index 0000000..f0c0a44
Binary files /dev/null and b/doc/SDF textures/sdf-far-zoom.png differ
diff --git a/doc/SDF textures/sdf-far.png b/doc/SDF textures/sdf-far.png
new file mode 100644 (file)
index 0000000..64898a2
Binary files /dev/null and b/doc/SDF textures/sdf-far.png differ
diff --git a/doc/SDF textures/sdf-mid.png b/doc/SDF textures/sdf-mid.png
new file mode 100644 (file)
index 0000000..ba8ed3e
Binary files /dev/null and b/doc/SDF textures/sdf-mid.png differ
diff --git a/doc/SDF textures/sdf-near.png b/doc/SDF textures/sdf-near.png
new file mode 100644 (file)
index 0000000..1d493a1
Binary files /dev/null and b/doc/SDF textures/sdf-near.png differ
diff --git a/doc/Shading/Ambient light comparison.svg b/doc/Shading/Ambient light comparison.svg
new file mode 100644 (file)
index 0000000..ce07a00
--- /dev/null
@@ -0,0 +1,51 @@
+<svg viewBox="0 0 640 290" width="640" height="290" xmlns="http://www.w3.org/2000/svg">
+<defs>
+  <filter id="glows"><feGaussianBlur stdDeviation="1.5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+<mask id="imagine-text-gaps-2vose8" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="640" height="290" fill="white"/><rect x="261.183349609375" y="6.583333969116211" width="117.63333129882812" height="20.91666603088379" fill="black" rx="2"/><rect x="110.16667175292969" y="26.25" width="419.6666564941406" height="15.083333015441895" fill="black" rx="2"/><rect x="53.083335876464844" y="223.25" width="83.83333587646484" height="15.083333015441895" fill="black" rx="2"/><rect x="54.900001525878906" y="237.6666717529297" width="80.19999694824219" height="16.25" fill="black" rx="2"/><rect x="35.608333587646484" y="252.4166717529297" width="118.78333282470703" height="13.916666984558105" fill="black" rx="2"/><rect x="257.9583435058594" y="223.25" width="100.08333587646484" height="15.083333015441895" fill="black" rx="2"/><rect x="273.9166564941406" y="237.6666717529297" width="68.16666793823242" height="16.25" fill="black" rx="2"/><rect x="267.875" y="252.4166717529297" width="80.25" height="13.916666984558105" fill="black" rx="2"/><rect x="463.8333435058594" y="223.25" width="116.33333587646484" height="15.083333015441895" fill="black" rx="2"/><rect x="487.9166564941406" y="237.6666717529297" width="68.16666793823242" height="16.25" fill="black" rx="2"/><rect x="477.058349609375" y="252.4166717529297" width="89.88333129882812" height="13.916666984558105" fill="black" rx="2"/><rect x="161.86666870117188" y="267.4166564941406" width="316.26666259765625" height="13.916666984558105" fill="black" rx="2"/></mask></defs>
+<rect width="640" height="280" fill="#061018" style="fill:rgb(6, 16, 24);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+
+<text x="320" y="22" fill="#ccc" font-size="14" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)" style="fill:rgb(204, 204, 204);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:14px;font-weight:700;text-anchor:middle;dominant-baseline:auto">Ambient Light</text>
+<text x="320" y="37" fill="#3a4a5a" font-size="9" font-family="monospace" text-anchor="middle" style="fill:rgb(58, 74, 90);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto">base illumination applied to all surfaces equally, regardless of orientation</text>
+
+<line x1="213" y1="42" x2="213" y2="268" stroke="#0c1a26" stroke-width="1.5" mask="url(#imagine-text-gaps-2vose8)" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="427" y1="42" x2="427" y2="268" stroke="#0c1a26" stroke-width="1.5" mask="url(#imagine-text-gaps-2vose8)" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="5" y1="220" x2="208" y2="220" stroke="#0c1a26" stroke-width="1" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="218" y1="220" x2="422" y2="220" stroke="#0c1a26" stroke-width="1" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="432" y1="220" x2="635" y2="220" stroke="#0c1a26" stroke-width="1" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+
+<!-- PANEL 1: No ambient -->
+<circle cx="30" cy="57" r="7" fill="rgba(255,102,0,0.45)" stroke="#FF6600" stroke-width="1.5" style="fill:rgba(255, 102, 0, 0.45);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="30" y1="48" x2="30" y2="42" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="37" y1="51" x2="42" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="23" y1="51" x2="18" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="22,82 102,70 102,200 22,212" fill="rgba(48,160,80,0.68)" stroke="#30a050" stroke-width="1.5" style="fill:rgba(48, 160, 80, 0.68);stroke:rgb(48, 160, 80);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="102,70 168,85 168,215 102,200" fill="rgba(5,10,7,0.97)" stroke="#1c2820" stroke-width="1.5" style="fill:rgba(5, 10, 7, 0.97);stroke:rgb(28, 40, 32);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="95" y="234" fill="rgba(208,64,64,0.75)" font-size="9" font-family="monospace" text-anchor="middle" style="fill:rgba(208, 64, 64, 0.75);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Color(0, 0, 0)</text>
+<text x="95" y="249" fill="rgba(208,64,64,0.9)" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)" style="fill:rgba(208, 64, 64, 0.9);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:700;text-anchor:middle;dominant-baseline:auto">✗ pure black</text>
+<text x="95" y="262" fill="#3a4a5a" font-size="8" font-family="monospace" text-anchor="middle" style="fill:rgb(58, 74, 90);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:middle;dominant-baseline:auto">harsh shadows, no depth</text>
+
+<!-- PANEL 2: Default ambient (50,50,50) -->
+<circle cx="243" cy="57" r="7" fill="rgba(255,102,0,0.45)" stroke="#FF6600" stroke-width="1.5" style="fill:rgba(255, 102, 0, 0.45);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="243" y1="48" x2="243" y2="42" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="250" y1="51" x2="255" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="236" y1="51" x2="231" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="235,82 315,70 315,200 235,212" fill="rgba(48,160,80,0.68)" stroke="#30a050" stroke-width="1.5" style="fill:rgba(48, 160, 80, 0.68);stroke:rgb(48, 160, 80);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="315,70 381,85 381,215 315,200" fill="rgba(40,75,46,0.75)" stroke="#285c30" stroke-width="1" style="fill:rgba(40, 75, 46, 0.75);stroke:rgb(40, 92, 48);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="308" y="234" fill="#39FF14" font-size="9" font-family="monospace" text-anchor="middle" style="fill:rgb(57, 255, 20);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Color(50, 50, 50)</text>
+<text x="308" y="249" fill="#39FF14" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)" style="fill:rgb(57, 255, 20);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:700;text-anchor:middle;dominant-baseline:auto">✓ balanced</text>
+<text x="308" y="262" fill="#3a4a5a" font-size="8" font-family="monospace" text-anchor="middle" style="fill:rgb(58, 74, 90);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:middle;dominant-baseline:auto">depth preserved</text>
+
+<!-- PANEL 3: Too much ambient (150,150,150) -->
+<circle cx="457" cy="57" r="7" fill="rgba(255,102,0,0.45)" stroke="#FF6600" stroke-width="1.5" style="fill:rgba(255, 102, 0, 0.45);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="457" y1="48" x2="457" y2="42" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="464" y1="51" x2="469" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<line x1="450" y1="51" x2="445" y2="46" stroke="#FF6600" stroke-width="1.1" opacity="0.6" style="fill:rgb(0, 0, 0);stroke:rgb(255, 102, 0);color:rgb(251, 251, 254);stroke-width:1.1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.6;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="449,82 529,70 529,200 449,212" fill="rgba(48,160,80,0.68)" stroke="#30a050" stroke-width="1.5" style="fill:rgba(48, 160, 80, 0.68);stroke:rgb(48, 160, 80);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<polygon points="529,70 595,85 595,215 529,200" fill="rgba(85,145,92,0.72)" stroke="#30a050" stroke-width="1.5" style="fill:rgba(85, 145, 92, 0.72);stroke:rgb(48, 160, 80);color:rgb(251, 251, 254);stroke-width:1.5px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="522" y="234" fill="#FF6600" font-size="9" font-family="monospace" text-anchor="middle" style="fill:rgb(255, 102, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:9px;font-weight:400;text-anchor:middle;dominant-baseline:auto">Color(150, 150, 150)</text>
+<text x="522" y="249" fill="#FF6600" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)" style="fill:rgb(255, 102, 0);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:10px;font-weight:700;text-anchor:middle;dominant-baseline:auto">✗ too flat</text>
+<text x="522" y="262" fill="#3a4a5a" font-size="8" font-family="monospace" text-anchor="middle" style="fill:rgb(58, 74, 90);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:middle;dominant-baseline:auto">no depth contrast</text>
+
+<line x1="20" y1="268" x2="620" y2="268" stroke="#0c1a26" stroke-width="1" mask="url(#imagine-text-gaps-2vose8)" style="fill:rgb(0, 0, 0);stroke:rgb(12, 26, 38);color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
+<text x="320" y="277" fill="#2a3a4a" font-size="8" font-family="monospace" text-anchor="middle" style="fill:rgb(42, 58, 74);stroke:none;color:rgb(251, 251, 254);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:monospace;font-size:8px;font-weight:400;text-anchor:middle;dominant-baseline:auto">lightingManager.setAmbientLight(new Color(50, 50, 50))  ←  default</text>
+</svg>
\ No newline at end of file
diff --git a/doc/Shading/Distance attenuation.svg b/doc/Shading/Distance attenuation.svg
new file mode 100644 (file)
index 0000000..2edf492
--- /dev/null
@@ -0,0 +1,91 @@
+<svg viewBox="0 0 640 295" width="640" height="295" xmlns="http://www.w3.org/2000/svg">
+<defs>
+  <filter id="glows"><feGaussianBlur stdDeviation="1.5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  <marker id="ax" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="5" markerHeight="5" orient="auto">
+    <path d="M 0 0 L 8 4 L 0 8 z" fill="#3a5060"/>
+  </marker>
+</defs>
+<rect width="640" height="295" fill="#061018"/>
+
+<text x="320" y="22" fill="#ccc" font-size="14" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)">Distance Attenuation</text>
+<text x="320" y="37" fill="#3a4a5a" font-size="9" font-family="monospace" text-anchor="middle">light intensity falls off with distance from source</text>
+
+<line x1="400" y1="45" x2="400" y2="282" stroke="#0c1a26" stroke-width="1.5"/>
+
+<!-- Light source -->
+<circle cx="52" cy="110" r="22" fill="rgba(255,102,0,0.06)"/>
+<circle cx="52" cy="110" r="14" fill="rgba(255,102,0,0.2)" stroke="rgba(255,102,0,0.45)" stroke-width="1.5"/>
+<circle cx="52" cy="110" r="6" fill="rgba(255,102,0,0.7)" stroke="#FF6600" stroke-width="1.5"/>
+<line x1="52" y1="92" x2="52" y2="84" stroke="#FF6600" stroke-width="1.1" opacity="0.55"/>
+<line x1="65" y1="97" x2="72" y2="90" stroke="#FF6600" stroke-width="1.1" opacity="0.55"/>
+<line x1="39" y1="97" x2="32" y2="90" stroke="#FF6600" stroke-width="1.1" opacity="0.55"/>
+<line x1="70" y1="110" x2="78" y2="110" stroke="#FF6600" stroke-width="1.1" opacity="0.55"/>
+<line x1="65" y1="123" x2="72" y2="130" stroke="#FF6600" stroke-width="1.1" opacity="0.55"/>
+<text x="52" y="82" fill="#FF6600" font-size="9" font-weight="700" font-family="monospace" text-anchor="middle">Light</text>
+
+<line x1="52" y1="155" x2="380" y2="155" stroke="#1a2a3a" stroke-width="1" stroke-dasharray="4 3"/>
+
+<!-- Surface d=100, att=0.99 -->
+<line x1="65" y1="110" x2="126" y2="110" stroke="#FF6600" stroke-width="1" stroke-dasharray="4 2" opacity="0.40"/>
+<polygon points="128,77 163,77 158,148 133,148" fill="rgba(48,160,80,0.70)" stroke="#30a050" stroke-width="1.5"/>
+<text x="145" y="65" fill="#30a050" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)">0.99</text>
+<line x1="145" y1="152" x2="145" y2="160" stroke="#2a3a4a" stroke-width="1"/>
+<text x="145" y="172" fill="#555" font-size="8" font-family="monospace" text-anchor="middle">d = 100</text>
+
+<!-- Surface d=300, att=0.52 -->
+<line x1="65" y1="110" x2="223" y2="110" stroke="#FF6600" stroke-width="1" stroke-dasharray="4 2" opacity="0.22"/>
+<polygon points="225,77 259,77 255,148 229,148" fill="rgba(48,160,80,0.36)" stroke="rgba(48,160,80,0.65)" stroke-width="1.2"/>
+<text x="242" y="65" fill="rgba(48,160,80,0.8)" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">0.52</text>
+<line x1="242" y1="152" x2="242" y2="160" stroke="#2a3a4a" stroke-width="1"/>
+<text x="242" y="172" fill="#555" font-size="8" font-family="monospace" text-anchor="middle">d = 300</text>
+
+<!-- Surface d=500, att=0.29 -->
+<line x1="65" y1="110" x2="316" y2="110" stroke="#FF6600" stroke-width="1" stroke-dasharray="4 2" opacity="0.10"/>
+<polygon points="318,77 352,77 349,148 321,148" fill="rgba(48,160,80,0.18)" stroke="rgba(48,160,80,0.38)" stroke-width="1"/>
+<text x="335" y="65" fill="rgba(48,160,80,0.55)" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">0.29</text>
+<line x1="335" y1="152" x2="335" y2="160" stroke="#2a3a4a" stroke-width="1"/>
+<text x="335" y="172" fill="#555" font-size="8" font-family="monospace" text-anchor="middle">d = 500</text>
+
+<text x="195" y="192" fill="#2a3a4a" font-size="8" font-family="monospace" text-anchor="middle">← attenuation factor shown above each surface →</text>
+
+<!-- Chart -->
+<text x="513" y="57" fill="#aaa" font-size="9" font-weight="700" font-family="monospace" text-anchor="middle">attenuation vs distance</text>
+<line x1="415" y1="210" x2="630" y2="210" stroke="#2a3a4a" stroke-width="1" marker-end="url(#ax)"/>
+<line x1="415" y1="210" x2="415" y2="67" stroke="#2a3a4a" stroke-width="1" marker-end="url(#ax)"/>
+<text x="622" y="222" fill="#3a5060" font-size="8" font-family="monospace">d</text>
+<text x="408" y="65" fill="#3a5060" font-size="8" font-family="monospace" text-anchor="end">att</text>
+<line x1="411" y1="210" x2="419" y2="210" stroke="#2a3a4a" stroke-width="1"/>
+<text x="408" y="213" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="end">0</text>
+<line x1="411" y1="138" x2="419" y2="138" stroke="#2a3a4a" stroke-width="1"/>
+<line x1="415" y1="138" x2="625" y2="138" stroke="#0d1e2e" stroke-width="1"/>
+<text x="408" y="141" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="end">0.5</text>
+<line x1="411" y1="67" x2="419" y2="67" stroke="#2a3a4a" stroke-width="1"/>
+<line x1="415" y1="67" x2="625" y2="67" stroke="#0d1e2e" stroke-width="1"/>
+<text x="408" y="70" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="end">1.0</text>
+<line x1="450" y1="206" x2="450" y2="214" stroke="#2a3a4a" stroke-width="1"/>
+<line x1="450" y1="67" x2="450" y2="210" stroke="#0d1e2e" stroke-width="1" stroke-dasharray="2 4"/>
+<text x="450" y="222" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="middle">100</text>
+<line x1="520" y1="206" x2="520" y2="214" stroke="#2a3a4a" stroke-width="1"/>
+<line x1="520" y1="67" x2="520" y2="210" stroke="#0d1e2e" stroke-width="1" stroke-dasharray="2 4"/>
+<text x="520" y="222" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="middle">300</text>
+<line x1="590" y1="206" x2="590" y2="214" stroke="#2a3a4a" stroke-width="1"/>
+<line x1="590" y1="67" x2="590" y2="210" stroke="#0d1e2e" stroke-width="1" stroke-dasharray="2 4"/>
+<text x="590" y="222" fill="#3a5060" font-size="7" font-family="monospace" text-anchor="middle">500</text>
+<path d="M 415,67 C 430,67 440,68 450,68 C 468,68 492,100 520,136 C 548,170 575,175 625,178"
+      fill="none" stroke="#FF6600" stroke-width="2" opacity="0.85"/>
+<circle cx="415" cy="67" r="3" fill="#FF6600" opacity="0.70"/>
+<circle cx="450" cy="68" r="3" fill="#FF6600" opacity="0.85"/>
+<circle cx="520" cy="136" r="3" fill="#FF6600" opacity="0.85"/>
+<circle cx="590" cy="169" r="3" fill="#FF6600" opacity="0.85"/>
+<text x="453" y="62" fill="rgba(255,102,0,0.75)" font-size="7" font-family="monospace">0.99</text>
+<text x="523" y="131" fill="rgba(255,102,0,0.75)" font-size="7" font-family="monospace">0.52</text>
+<text x="593" y="164" fill="rgba(255,102,0,0.75)" font-size="7" font-family="monospace">0.29</text>
+
+<!-- Formula box -->
+<rect x="405" y="230" width="225" height="46" rx="4" fill="rgba(32,112,192,0.08)" stroke="#2070c0" stroke-width="1.5"/>
+<text x="517" y="249" fill="#2070c0" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">attenuation =</text>
+<text x="517" y="267" fill="#2070c0" font-size="11" font-family="monospace" text-anchor="middle">1 / (1 + 0.0001 · d²)</text>
+
+<line x1="20" y1="282" x2="620" y2="282" stroke="#0c1a26" stroke-width="1"/>
+<text x="320" y="291" fill="#2a3a4a" font-size="8" font-family="monospace" text-anchor="middle">coefficient 0.0001 was tuned for typical scene scales in Aukio 3D</text>
+</svg>
diff --git a/doc/Shading/Lambert cosine law.svg b/doc/Shading/Lambert cosine law.svg
new file mode 100644 (file)
index 0000000..1f4e216
--- /dev/null
@@ -0,0 +1,92 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+<defs>
+  <filter id="glow"><feGaussianBlur stdDeviation="2" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  <filter id="glows"><feGaussianBlur stdDeviation="1.5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
+  <marker id="an" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/></marker>
+  <marker id="al" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="#FF6600"/></marker>
+</defs>
+<rect width="640" height="480" fill="#061018"/>
+<text x="320" y="30" fill="#ccc" font-size="17" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)">Lambert Cosine Law</text>
+<text x="320" y="48" fill="#3a4a5a" font-size="10" font-family="monospace" text-anchor="middle">how surface orientation determines light intensity</text>
+<line x1="385" y1="58" x2="385" y2="305" stroke="#0c1a26" stroke-width="1.5"/>
+<line x1="20" y1="308" x2="620" y2="308" stroke="#0c1a26" stroke-width="1.5"/>
+<polygon points="60,275 280,255 295,165 75,185" fill="none" stroke="rgba(48,160,80,0.15)" stroke-width="8"/>
+<polygon points="60,275 280,255 295,165 75,185" fill="rgba(48,160,80,0.13)" stroke="#30a050" stroke-width="2"/>
+<circle cx="178" cy="220" r="4" fill="#30a050"/>
+<line x1="178" y1="220" x2="148" y2="75" stroke="#30a050" stroke-width="2.5" marker-end="url(#an)"/>
+<text x="128" y="70" fill="#30a050" font-size="18" font-weight="700" font-family="monospace" filter="url(#glow)">N̂</text>
+<text x="130" y="84" fill="#aaa" font-size="9" font-family="monospace">normal</text>
+<circle cx="345" cy="85" r="28" fill="rgba(255,102,0,0.06)"/>
+<circle cx="345" cy="85" r="17" fill="rgba(255,102,0,0.2)" stroke="rgba(255,102,0,0.45)" stroke-width="1.5"/>
+<circle cx="345" cy="85" r="7" fill="rgba(255,102,0,0.7)" stroke="#FF6600" stroke-width="1.5"/>
+<line x1="345" y1="62" x2="345" y2="52" stroke="#FF6600" stroke-width="1.2" opacity="0.55"/>
+<line x1="362" y1="67" x2="370" y2="60" stroke="#FF6600" stroke-width="1.2" opacity="0.55"/>
+<line x1="328" y1="67" x2="320" y2="60" stroke="#FF6600" stroke-width="1.2" opacity="0.55"/>
+<line x1="368" y1="85" x2="377" y2="85" stroke="#FF6600" stroke-width="1.2" opacity="0.55"/>
+<line x1="322" y1="85" x2="313" y2="85" stroke="#FF6600" stroke-width="1.2" opacity="0.55"/>
+<text x="370" y="89" fill="#FF6600" font-size="11" font-weight="700" font-family="monospace" text-anchor="start">Light</text>
+<line x1="333" y1="97" x2="185" y2="215" stroke="#FF6600" stroke-width="2" stroke-dasharray="6 3" marker-end="url(#al)"/>
+<text x="274" y="147" fill="#FF6600" font-size="15" font-weight="700" font-family="monospace" filter="url(#glow)">L̂</text>
+<path d="M 167,166 A 55,55 0 0,1 221,185" fill="none" stroke="#b09020" stroke-width="2"/>
+<text x="213" y="160" fill="#b09020" font-size="14" font-weight="700" font-family="monospace" filter="url(#glows)">θ</text>
+<text x="178" y="244" fill="rgba(48,160,80,0.6)" font-size="10" font-family="monospace" text-anchor="middle">surface polygon</text>
+<rect x="398" y="68" width="218" height="105" rx="4" fill="rgba(32,112,192,0.08)" stroke="#2070c0" stroke-width="1.5"/>
+<text x="507" y="93" fill="#2070c0" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">brightness =</text>
+<text x="507" y="118" fill="#2070c0" font-size="16" font-weight="700" font-family="monospace" text-anchor="middle" filter="url(#glows)">dot( N̂ , L̂ )</text>
+<line x1="408" y1="127" x2="606" y2="127" stroke="#2070c0" stroke-width="1" opacity="0.3"/>
+<text x="507" y="152" fill="#2070c0" font-size="13" font-family="monospace" text-anchor="middle">= cos( θ )</text>
+<rect x="398" y="183" width="218" height="115" rx="4" fill="rgba(80,96,192,0.05)" stroke="rgba(80,96,192,0.4)" stroke-width="1"/>
+<text x="412" y="204" fill="#666" font-size="10" font-family="monospace">θ = 0°</text>
+<rect x="458" y="193" width="80" height="13" rx="2" fill="rgba(57,255,20,0.15)" stroke="#39FF14" stroke-width="1"/>
+<rect x="458" y="193" width="80" height="13" rx="2" fill="rgba(57,255,20,0.7)"/>
+<text x="548" y="204" fill="#39FF14" font-size="10" font-weight="700" font-family="monospace">1.00</text>
+<text x="412" y="225" fill="#666" font-size="10" font-family="monospace">θ = 45°</text>
+<rect x="458" y="214" width="80" height="13" rx="2" fill="rgba(48,160,80,0.08)" stroke="#30a050" stroke-width="1"/>
+<rect x="458" y="214" width="57" height="13" rx="2" fill="rgba(48,160,80,0.55)"/>
+<text x="548" y="225" fill="#bbb" font-size="10" font-family="monospace">0.71</text>
+<text x="412" y="246" fill="#666" font-size="10" font-family="monospace">θ = 90°</text>
+<rect x="458" y="235" width="80" height="13" rx="2" fill="rgba(48,160,80,0.04)" stroke="rgba(48,160,80,0.2)" stroke-width="1"/>
+<text x="548" y="246" fill="#555" font-size="10" font-family="monospace">0.00</text>
+<line x1="408" y1="255" x2="606" y2="255" stroke="rgba(80,96,192,0.3)" stroke-width="1"/>
+<text x="412" y="271" fill="#555" font-size="10" font-family="monospace">θ &gt; 90°</text>
+<text x="460" y="271" fill="rgba(208,64,64,0.75)" font-size="10" font-family="monospace">back-face → skip</text>
+<text x="412" y="287" fill="#3a4a5a" font-size="9" font-family="monospace">dot &lt; 0  →  no contribution</text>
+<text x="320" y="326" fill="#2a3a4a" font-size="10" font-family="monospace" text-anchor="middle">— angle examples —</text>
+<g transform="translate(40,338)">
+  <rect x="10" y="78" width="100" height="13" rx="2" fill="rgba(48,160,80,0.25)" stroke="#30a050" stroke-width="1.5"/>
+  <line x1="60" y1="78" x2="60" y2="32" stroke="#30a050" stroke-width="2" marker-end="url(#an)"/>
+  <circle cx="60" cy="13" r="7" fill="rgba(255,102,0,0.5)" stroke="#FF6600" stroke-width="1.5"/>
+  <line x1="60" y1="20" x2="60" y2="34" stroke="#FF6600" stroke-width="2" marker-end="url(#al)"/>
+  <text x="60" y="103" fill="#39FF14" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">θ = 0°</text>
+  <rect x="10" y="112" width="100" height="11" rx="2" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1"/>
+  <rect x="10" y="112" width="100" height="11" rx="2" fill="#30a050"/>
+  <text x="60" y="121" fill="#061018" font-size="8" font-weight="700" font-family="monospace" text-anchor="middle">100%</text>
+</g>
+<g transform="translate(250,338)">
+  <rect x="10" y="78" width="100" height="13" rx="2" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <line x1="60" y1="78" x2="60" y2="32" stroke="#30a050" stroke-width="2" marker-end="url(#an)"/>
+  <circle cx="104" cy="34" r="7" fill="rgba(255,102,0,0.5)" stroke="#FF6600" stroke-width="1.5"/>
+  <line x1="99" y1="39" x2="68" y2="70" stroke="#FF6600" stroke-width="2" marker-end="url(#al)"/>
+  <path d="M 60,50 A 28,28 0 0,1 80,58" fill="none" stroke="#b09020" stroke-width="1.5"/>
+  <text x="83" y="51" fill="#b09020" font-size="9" font-weight="700" font-family="monospace">45°</text>
+  <text x="60" y="103" fill="#bbb" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">θ = 45°</text>
+  <rect x="10" y="112" width="100" height="11" rx="2" fill="rgba(48,160,80,0.08)" stroke="#30a050" stroke-width="1"/>
+  <rect x="10" y="112" width="71" height="11" rx="2" fill="rgba(48,160,80,0.55)"/>
+  <text x="60" y="121" fill="#ccc" font-size="8" font-weight="700" font-family="monospace" text-anchor="middle">71%</text>
+</g>
+<g transform="translate(462,338)">
+  <rect x="10" y="78" width="100" height="13" rx="2" fill="rgba(208,64,64,0.06)" stroke="rgba(208,64,64,0.4)" stroke-width="1.5" stroke-dasharray="4 3"/>
+  <line x1="60" y1="78" x2="60" y2="32" stroke="#30a050" stroke-width="2" marker-end="url(#an)"/>
+  <circle cx="122" cy="78" r="7" fill="rgba(255,102,0,0.5)" stroke="#FF6600" stroke-width="1.5"/>
+  <line x1="115" y1="78" x2="76" y2="78" stroke="#FF6600" stroke-width="2" marker-end="url(#al)"/>
+  <path d="M 60,50 A 28,28 0 0,1 88,78" fill="none" stroke="#b09020" stroke-width="1.5"/>
+  <text x="80" y="57" fill="#b09020" font-size="9" font-weight="700" font-family="monospace">90°</text>
+  <text x="60" y="103" fill="rgba(208,64,64,0.8)" font-size="11" font-weight="700" font-family="monospace" text-anchor="middle">θ = 90°</text>
+  <rect x="10" y="112" width="100" height="11" rx="2" fill="rgba(208,64,64,0.05)" stroke="rgba(208,64,64,0.4)" stroke-width="1" stroke-dasharray="3 2"/>
+  <text x="60" y="121" fill="rgba(208,64,64,0.7)" font-size="8" font-weight="700" font-family="monospace" text-anchor="middle">0%  (skip)</text>
+</g>
+<line x1="40" y1="472" x2="62" y2="472" stroke="#30a050" stroke-width="2"/>
+<text x="68" y="476" fill="#30a050" font-size="9" font-family="monospace">N̂  surface normal</text>
+<line x1="250" y1="472" x2="272" y2="472" stroke="#FF6600" stroke-width="2" stroke-dasharray="5 2"/>
+<text x="278" y="476" fill="#FF6600" font-size="9" font-family="monospace">L̂  light direction</text>
+</svg>
diff --git a/doc/Shading/Shaded sphere.png b/doc/Shading/Shaded sphere.png
new file mode 100644 (file)
index 0000000..fbc6487
Binary files /dev/null and b/doc/Shading/Shaded sphere.png differ
diff --git a/doc/Shading/Shading pipeline.svg b/doc/Shading/Shading pipeline.svg
new file mode 100644 (file)
index 0000000..a58c431
--- /dev/null
@@ -0,0 +1,35 @@
+<svg viewBox="0 0 620 80" width="620" height="80" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrowhead2" viewBox="0 0 10 10" refX="9" refY="5"
+            markerWidth="6" markerHeight="6" orient="auto">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+  </defs>
+  <rect width="620" height="80" fill="#061018"/>
+
+  <!-- Transform phase (where shading happens) -->
+  <rect x="140" y="25" width="90" height="30" rx="3" fill="rgba(176,144,32,0.15)" stroke="#b09020" stroke-width="2"/>
+  <text x="185" y="43" fill="#b09020" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Transform</text>
+  <text x="185" y="67" fill="#b09020" font-size="8" font-family="monospace" text-anchor="middle">compute lighting</text>
+
+  <!-- Other phases -->
+  <rect x="15" y="25" width="90" height="30" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
+  <text x="60" y="43" fill="#30a050" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Shapes</text>
+
+  <rect x="265" y="25" width="70" height="30" rx="3" fill="rgba(200,80,140,0.15)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="300" y="43" fill="#c05088" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Sort</text>
+
+  <rect x="365" y="25" width="80" height="30" rx="3" fill="rgba(255,102,0,0.15)" stroke="#FF6600" stroke-width="1.5"/>
+  <text x="405" y="43" fill="#FF6600" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Paint</text>
+  <text x="405" y="67" fill="#bbb" font-size="8" font-family="monospace" text-anchor="middle">use cached color</text>
+
+  <rect x="480" y="25" width="60" height="30" rx="3" fill="rgba(57,255,20,0.15)" stroke="#39FF14" stroke-width="1.5"/>
+  <text x="510" y="43" fill="#39FF14" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Blit</text>
+
+  <!-- Arrows -->
+  <line x1="105" y1="40" x2="135" y2="40" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <line x1="230" y1="40" x2="260" y2="40" stroke="#2070c0" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <line x1="335" y1="40" x2="360" y2="40" stroke="#c05088" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <line x1="445" y1="40" x2="475" y2="40" stroke="#FF6600" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+  <line x1="540" y1="40" x2="565" y2="40" stroke="#39FF14" stroke-width="1.5" marker-end="url(#arrowhead2)"/>
+</svg>
diff --git a/doc/Shading/index.org b/doc/Shading/index.org
new file mode 100644 (file)
index 0000000..fdf95a0
--- /dev/null
@@ -0,0 +1,266 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Shading & Lighting - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="../style.css"/>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* Overview
+:PROPERTIES:
+:CUSTOM_ID: shading-lighting
+:END:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Shaded sphere.png]]
+
+*Aukio 3D* implements *flat shading* using the [[https://en.wikipedia.org/wiki/Lambert%27s_cosine_law][Lambert cosine
+law]]. Each polygon receives a single color based on its orientation
+relative to light sources. This is a simple yet effective lighting
+model that gives 3D objects depth and realism.
+
+** The Lighting Model: Lambert Cosine Law
+:PROPERTIES:
+:CUSTOM_ID: lambert-cosine-law
+:END:
+
+#+INCLUDE: "Lambert cosine law.svg" export html
+
+The *Lambert cosine law* determines how much light a surface receives
+based on its orientation. A surface facing directly toward a light source
+receives maximum illumination; as it tilts away, the illumination decreases
+proportionally until it reaches zero when perpendicular to the light
+direction. This fundamental principle creates the visual cues that make 3D
+objects appear solid and dimensional rather than flat.
+
+The engine implements this law through the dot product of two vectors. The
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] computes a unit vector pointing from the polygon's center
+to each [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]], then calculates the dot product with the surface
+normal. When the dot product equals 1.0, the surface faces the light
+directly and receives full brightness. At 0.71 (a 45-degree angle), it
+receives about 71% illumination. At zero or below, the surface faces away
+from the light and receives no direct contribution from that source. The
+implementation in [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager.computeLighting()]] explicitly checks for
+positive dot products before adding light contributions, ensuring that
+back-facing surfaces skip unnecessary calculations.
+
+The surface normal itself is computed by [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]], which takes
+the first three vertices of a polygon and calculates their cross product to
+find the perpendicular direction. This normal, along with the polygon's
+center point calculated by [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]], is passed to the lighting manager
+during the [[file:../Rendering loop/][transform phase]] of the rendering loop. The transform phase runs
+in parallel, but each polygon is transformed by exactly one worker per
+pass, so its cached =shadedColor= field has a single writer — the
+result is reused allocation-free during the subsequent multi-threaded
+paint phase. See the
+[[file:../index.org::#normal-vector][Normal Vector]] section for more details on how normals are computed and used
+throughout the engine.
+
+* Light Sources
+:PROPERTIES:
+:CUSTOM_ID: light-sources
+:END:
+
+Each light source has three properties:
+
+| Property   | Description                          |
+|------------+--------------------------------------|
+| Position   | 3D world coordinates of the light    |
+| Color      | RGB color of emitted light           |
+| Intensity  | Brightness multiplier (1.0 = normal) |
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+// Create a bright yellow light to the right
+LightSource rightLight = new LightSource(
+    new Point3D(200, -100, 0),  // position: right, above, at viewer level
+    Color.YELLOW,               // color
+    2.0                         // intensity: extra bright
+);
+
+// Create a dim blue light from the left
+LightSource leftLight = new LightSource(
+    new Point3D(-150, 50, 100),
+    Color.BLUE,
+    0.5                         // intensity: dim
+);
+#+END_SRC
+
+Multiple light sources add their contributions together, allowing for
+complex lighting setups like the screenshot above showing a sphere lit
+by two lights from the right.
+
+** Distance Attenuation
+:PROPERTIES:
+:CUSTOM_ID: distance-attenuation
+:END:
+
+#+INCLUDE: "Distance attenuation.svg" export html
+
+Light intensity decreases with distance using a *simplified inverse
+square law*:
+
+#+BEGIN_SRC
+attenuation = 1.0 / (1.0 + 0.0001 * distance²)
+#+END_SRC
+
+- At distance 0: attenuation = 1.0 (full intensity)
+- At distance 100: attenuation ≈ 0.99 (almost full)
+- At distance 300: attenuation ≈ 0.52 (half intensity)
+- At distance 500: attenuation ≈ 0.29 (about 30%)
+
+This simplified formula prevents harsh cutoffs while still providing
+distance-based dimming. The =0.0001= coefficient was tuned for typical
+scene scales in Aukio 3D.
+
+* Ambient Light
+:PROPERTIES:
+:CUSTOM_ID: ambient-light
+:END:
+
+#+INCLUDE: "Ambient light comparison.svg" export html
+
+*Ambient light* provides base illumination that affects all surfaces
+equally, regardless of orientation. Without ambient light, surfaces not
+directly facing a light source would be pure black.
+
+- Default ambient: =Color(50, 50, 50)= (dim gray) — set by the ViewPanel
+  constructor; a standalone =new LightingManager()= starts at
+  =Color(10, 10, 10)=
+- Configurable via =lightingManager.setAmbientLight()=
+- Too much ambient: flat appearance (no contrast)
+- Too little ambient: harsh shadows (pure black areas)
+
+#+BEGIN_SRC java
+// Increase ambient for softer shadows
+viewPanel.getLightingManager().setAmbientLight(new Color(80, 80, 80));
+
+// Reduce ambient for dramatic contrast
+viewPanel.getLightingManager().setAmbientLight(new Color(20, 20, 20));
+#+END_SRC
+
+* Using Shading in Your Scene
+:PROPERTIES:
+:CUSTOM_ID: using-shading
+:END:
+
+**Adding light sources:**
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+
+ViewPanel viewPanel = new ViewPanel();
+
+// Get the lighting manager
+LightingManager lighting = viewPanel.getLightingManager();
+
+// Add light sources
+lighting.addLight(new LightSource(
+    new Point3D(200, -100, 0),  // right side, above
+    Color.YELLOW,
+    1.5                         // bright
+));
+
+lighting.addLight(new LightSource(
+    new Point3D(-100, 0, 200),  // left side, further away
+    new Color(255, 200, 150),   // warm white
+    1.0
+));
+
+// Configure ambient light
+lighting.setAmbientLight(new Color(40, 40, 40));
+#+END_SRC
+
+**Enabling shading on shapes:**
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox;
+
+// Create a shaded box
+SolidPolygonRectangularBox box = new SolidPolygonRectangularBox(
+    new Point3D(-50, -50, 100),  // min corner
+    new Point3D(50, 50, 200),   // max corner
+    Color.RED
+);
+
+// Enable shading on the box and all its sub-polygons
+box.setShadingEnabled(true);
+
+// Also enable backface culling for closed meshes
+box.setBackfaceCulling(true);
+
+// Add to scene
+viewPanel.getRootShapeCollection().addShape(box);
+#+END_SRC
+
+Shading propagates through composite shapes — calling
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setShadingEnabled(boolean)][setShadingEnabled(true)]] on a composite enables shading for all its
+sub-polygons.
+
+** Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Purpose |
+|-------+---------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] | Manages light sources and computes shading |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]] | Individual light with position, color, intensity |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape with shading support |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Composite shape with shading propagation |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html][ViewPanel]] | Provides access to LightingManager |
+* Implementation details
+:PROPERTIES:
+:CUSTOM_ID: implementation-details
+:END:
+
+#+INCLUDE: "Shading pipeline.svg" export html
+
+Lighting is computed during *Phase 1* (transform phase) of the
+[[file:../Rendering loop/][rendering loop]]:
+
+1. Each shaded polygon calculates its center point and surface normal
+2. [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] computes lighting from all sources
+3. Result stored in reusable =shadedColor= field
+4. During *Phase 4* (paint), the cached color is used directly
+
+**Why during transform phase?**
+
+- Lighting computed *once per polygon per pass* — not per pixel
+- Each polygon is transformed by a single worker, so its cached result
+  has exactly one writer even though the transform phase runs in parallel
+- Result reused during multi-threaded paint phase — efficient
+
+** Performance Characteristics
+:PROPERTIES:
+:CUSTOM_ID: performance
+:END:
+
+| Aspect       | Cost                          |
+|--------------+-------------------------------|
+| Computation  | Per polygon, not per pixel    |
+| Phase        | Parallel transform (single writer per polygon) |
+| Allocation   | Zero (reuses Color instance)  |
+| Cache        | One shadedColor per polygon   |
+
+The shading implementation is optimized for CPU rendering:
+
+- *Flat shading*: One lighting calculation per polygon (N-vertex polygon = 1 calculation)
+- *Reusable Color*: Result stored in existing field, no allocation during render
+- *Thread-safe*: One writer per polygon per pass, so no synchronization needed
+- *Pre-computed*: All paint workers (tile grid, ~75% of CPU cores by default) read the same cached result
+
+This approach trades visual fidelity (no per-pixel lighting) for
+performance — essential for software rendering where per-pixel lighting
+would be prohibitively expensive.
diff --git a/doc/Stereoscopic rendering/Stereo geometry.svg b/doc/Stereoscopic rendering/Stereo geometry.svg
new file mode 100644 (file)
index 0000000..2f62d50
--- /dev/null
@@ -0,0 +1,79 @@
+<svg viewBox="0 0 640 460" width="640" height="460" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+
+  <!-- background -->
+  <rect width="640" height="460" fill="#061018"/>
+
+  <!-- faint grid -->
+  <g stroke="#1a3a4a" stroke-width="0.5">
+    <line x1="60" y1="60" x2="600" y2="60"/>
+    <line x1="60" y1="130" x2="600" y2="130"/>
+    <line x1="60" y1="200" x2="600" y2="200"/>
+    <line x1="60" y1="270" x2="600" y2="270"/>
+    <line x1="60" y1="340" x2="600" y2="340"/>
+    <line x1="60" y1="410" x2="600" y2="410"/>
+  </g>
+
+  <text x="320" y="34" fill="#40b0d0" font-size="17" font-family="monospace" text-anchor="middle" filter="url(#glow)">Two parallel cameras, one screen</text>
+  <text x="320" y="52" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">top-down view of the scene (z grows downward = into the scene)</text>
+
+  <!-- projection plane line (screen) -->
+  <line x1="100" y1="140" x2="560" y2="140" stroke="#c05088" stroke-width="2" filter="url(#glow)"/>
+  <text x="560" y="130" fill="#c05088" font-size="10" font-family="monospace" text-anchor="end">screen plane (per eye)</text>
+
+  <!-- world objects -->
+  <circle cx="330" cy="240" r="8" fill="#FF8833" filter="url(#glow)"/>
+  <text x="330" y="262" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle">near object</text>
+  <circle cx="330" cy="380" r="8" fill="#2070c0" filter="url(#glow)"/>
+  <text x="330" y="402" fill="#2070c0" font-size="10" font-family="monospace" text-anchor="middle">far object</text>
+
+  <!-- eyes -->
+  <circle cx="240" cy="70" r="7" fill="#39FF14" filter="url(#glow)"/>
+  <text x="222" y="74" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="end">left eye</text>
+  <text x="222" y="88" fill="#39FF14" font-size="9" font-family="monospace" text-anchor="end">x - IPD/2</text>
+  <circle cx="420" cy="70" r="7" fill="#40b0d0" filter="url(#glow)"/>
+  <text x="438" y="74" fill="#40b0d0" font-size="11" font-family="monospace">right eye</text>
+  <text x="438" y="88" fill="#40b0d0" font-size="9" font-family="monospace">x + IPD/2</text>
+
+  <!-- IPD brace -->
+  <line x1="240" y1="98" x2="420" y2="98" stroke="#b09020" stroke-width="1.5" filter="url(#glow)"/>
+  <line x1="240" y1="92" x2="240" y2="104" stroke="#b09020" stroke-width="1.5"/>
+  <line x1="420" y1="92" x2="420" y2="104" stroke="#b09020" stroke-width="1.5"/>
+  <text x="330" y="92" fill="#b09020" font-size="11" font-family="monospace" text-anchor="middle" filter="url(#glow)">IPD = 6.5 units (cm)</text>
+
+  <!-- rays: left eye -->
+  <line x1="240" y1="70" x2="330" y2="240" stroke="#39FF14" stroke-width="1.5" stroke-opacity="0.8"/>
+  <line x1="240" y1="70" x2="330" y2="380" stroke="#39FF14" stroke-width="1" stroke-opacity="0.45" stroke-dasharray="5 3"/>
+  <!-- rays: right eye -->
+  <line x1="420" y1="70" x2="330" y2="240" stroke="#40b0d0" stroke-width="1.5" stroke-opacity="0.8"/>
+  <line x1="420" y1="70" x2="330" y2="380" stroke="#40b0d0" stroke-width="1" stroke-opacity="0.45" stroke-dasharray="5 3"/>
+
+  <!-- projections of the near object on the screen plane -->
+  <!-- left eye: ray from (240,70) to (330,240); at y=140: t=(140-70)/(240-70)=0.4118, x=240+0.4118*90=277 -->
+  <circle cx="277" cy="140" r="4" fill="#FF8833" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+  <!-- right eye: ray from (420,70) to (330,240); at y=140: x=420-0.4118*90=383 -->
+  <circle cx="383" cy="140" r="4" fill="#FF8833" stroke="#40b0d0" stroke-width="1.5" filter="url(#glow)"/>
+  <!-- projections of the far object -->
+  <!-- left: (240,70)->(330,380); t=(140-70)/(380-70)=0.2258; x=240+0.2258*90=260 -->
+  <circle cx="260" cy="140" r="3.5" fill="#2070c0" stroke="#39FF14" stroke-width="1.5"/>
+  <!-- right: x=420-0.2258*90=400 -->
+  <circle cx="400" cy="140" r="3.5" fill="#2070c0" stroke="#40b0d0" stroke-width="1.5"/>
+
+  <!-- disparity braces on the screen plane -->
+  <line x1="277" y1="152" x2="383" y2="152" stroke="#FF8833" stroke-width="1.5" filter="url(#glow)"/>
+  <line x1="277" y1="147" x2="277" y2="157" stroke="#FF8833" stroke-width="1.5"/>
+  <line x1="383" y1="147" x2="383" y2="157" stroke="#FF8833" stroke-width="1.5"/>
+  <text x="330" y="168" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle">large disparity = close</text>
+  <line x1="260" y1="126" x2="400" y2="126" stroke="#2070c0" stroke-width="1" stroke-opacity="0.9"/>
+  <text x="330" y="120" fill="#2070c0" font-size="10" font-family="monospace" text-anchor="middle">small disparity = far</text>
+
+  <text x="60" y="446" fill="#999" font-size="10" font-family="monospace">cameras stay PARALLEL (no toe-in) - depth comes purely from the x offset</text>
+</svg>
diff --git a/doc/Stereoscopic rendering/Stereo per eye.svg b/doc/Stereoscopic rendering/Stereo per eye.svg
new file mode 100644 (file)
index 0000000..5fc9cfd
--- /dev/null
@@ -0,0 +1,49 @@
+<svg viewBox="0 0 640 300" width="640" height="300" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="1.5" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+  </defs>
+
+  <!-- background -->
+  <rect width="640" height="300" fill="#061018"/>
+
+  <text x="320" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">What changes per eye</text>
+
+  <!-- table-ish rows -->
+  <g font-family="monospace">
+    <!-- header -->
+    <text x="60" y="62" fill="#999" font-size="10">concern</text>
+    <text x="330" y="62" fill="#999" font-size="10">per-eye behavior</text>
+    <line x1="55" y1="70" x2="600" y2="70" stroke="#1a3a4a" stroke-width="1"/>
+
+    <text x="60" y="92" fill="#c05088" font-size="10">camera</text>
+    <text x="330" y="92" fill="#40b0d0" font-size="10">translation.x += &#177;IPD/2 (restored after the pass)</text>
+    <line x1="55" y1="100" x2="600" y2="100" stroke="#1a3a4a" stroke-width="0.5"/>
+
+    <text x="60" y="122" fill="#c05088" font-size="10">projection</text>
+    <text x="330" y="122" fill="#40b0d0" font-size="10">scale = eyeWidth/3; x += stereoViewportOffsetX</text>
+    <line x1="55" y1="130" x2="600" y2="130" stroke="#1a3a4a" stroke-width="0.5"/>
+
+    <text x="60" y="152" fill="#c05088" font-size="10">frustum culling</text>
+    <text x="330" y="152" fill="#40b0d0" font-size="10">built from stereoViewportWidth - narrower FOV per eye</text>
+    <line x1="55" y1="160" x2="600" y2="160" stroke="#1a3a4a" stroke-width="0.5"/>
+
+    <text x="60" y="182" fill="#c05088" font-size="10">painting</text>
+    <text x="330" y="182" fill="#40b0d0" font-size="10">clipped to [renderMinX, renderMaxX) = the eye's half</text>
+    <line x1="55" y1="190" x2="600" y2="190" stroke="#1a3a4a" stroke-width="0.5"/>
+
+    <text x="60" y="212" fill="#c05088" font-size="10">mouse picking</text>
+    <text x="330" y="212" fill="#40b0d0" font-size="10">hits combined only for the eye containing the cursor</text>
+    <line x1="55" y1="220" x2="600" y2="220" stroke="#1a3a4a" stroke-width="0.5"/>
+
+    <text x="60" y="242" fill="#c05088" font-size="10">HUD / overlays</text>
+    <text x="330" y="242" fill="#40b0d0" font-size="10">drawn once, spanning the full frame (zero disparity)</text>
+  </g>
+
+  <text x="60" y="282" fill="#999" font-size="9" font-family="monospace">everything else - geometry, textures, lightmaps, GI - is shared: the scene is identical, only the viewpoint moves</text>
+</svg>
diff --git a/doc/Stereoscopic rendering/Stereo pipeline.svg b/doc/Stereoscopic rendering/Stereo pipeline.svg
new file mode 100644 (file)
index 0000000..895e1fc
--- /dev/null
@@ -0,0 +1,68 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <filter id="glow">
+      <feGaussianBlur stdDeviation="2" result="blur"/>
+      <feMerge>
+        <feMergeNode in="blur"/>
+        <feMergeNode in="SourceGraphic"/>
+      </feMerge>
+    </filter>
+    <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+    </marker>
+    <marker id="arrowG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#39FF14"/>
+    </marker>
+  </defs>
+
+  <!-- background -->
+  <rect width="640" height="480" fill="#061018"/>
+
+  <text x="320" y="32" fill="#40b0d0" font-size="17" font-family="monospace" text-anchor="middle" filter="url(#glow)">One frame = two passes</text>
+  <text x="320" y="50" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">the triple-buffered pipeline runs the same phases twice, once per eye</text>
+
+  <!-- camera box -->
+  <rect x="230" y="70" width="180" height="44" rx="5" fill="rgba(176,144,32,0.08)" stroke="#b09020" stroke-width="1.5"/>
+  <text x="320" y="88" fill="#b09020" font-size="11" font-family="monospace" text-anchor="middle">camera</text>
+  <text x="320" y="104" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">translation.x nudged +/- IPD/2</text>
+
+  <!-- left pass lane -->
+  <rect x="40" y="150" width="250" height="60" rx="5" fill="rgba(57,255,20,0.06)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+  <text x="165" y="172" fill="#39FF14" font-size="12" font-family="monospace" text-anchor="middle">pass LEFT</text>
+  <text x="165" y="188" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">transform &#8594; sort &#8594; tile-bin</text>
+  <text x="165" y="201" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">viewport [0, w/2)</text>
+
+  <!-- right pass lane -->
+  <rect x="350" y="150" width="250" height="60" rx="5" fill="rgba(64,176,208,0.06)" stroke="#40b0d0" stroke-width="1.5" filter="url(#glow)"/>
+  <text x="475" y="172" fill="#40b0d0" font-size="12" font-family="monospace" text-anchor="middle">pass RIGHT</text>
+  <text x="475" y="188" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">transform &#8594; sort &#8594; tile-bin</text>
+  <text x="475" y="201" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">viewport [w/2, w)</text>
+
+  <!-- arrows from camera -->
+  <line x1="285" y1="114" x2="180" y2="148" stroke="#39FF14" stroke-width="1.5" marker-end="url(#arrowG)"/>
+  <line x1="355" y1="114" x2="460" y2="148" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#arrow)"/>
+
+  <!-- per-pass context note -->
+  <rect x="130" y="236" width="380" height="52" rx="5" fill="rgba(192,80,136,0.06)" stroke="#c05088" stroke-width="1.5"/>
+  <text x="320" y="256" fill="#c05088" font-size="10" font-family="monospace" text-anchor="middle">each pass owns a RenderingContext copy</text>
+  <text x="320" y="272" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">stereoEye, stereoViewportWidth/OffsetX, renderMinX..renderMaxX</text>
+
+  <line x1="165" y1="210" x2="250" y2="234" stroke="#39FF14" stroke-width="1.2" marker-end="url(#arrowG)"/>
+  <line x1="475" y1="210" x2="390" y2="234" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#arrow)"/>
+
+  <!-- shared frame buffer -->
+  <rect x="70" y="330" width="500" height="80" rx="5" fill="rgba(255,255,255,0.03)" stroke="#b09020" stroke-width="1.5" filter="url(#glow)"/>
+  <rect x="70" y="330" width="250" height="80" fill="rgba(57,255,20,0.07)"/>
+  <rect x="320" y="330" width="250" height="80" fill="rgba(64,176,208,0.07)"/>
+  <line x1="320" y1="330" x2="320" y2="410" stroke="#c05088" stroke-width="2" stroke-dasharray="6 4"/>
+  <text x="195" y="365" fill="#39FF14" font-size="11" font-family="monospace" text-anchor="middle">left eye pixels</text>
+  <text x="195" y="382" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">painting clipped to left half</text>
+  <text x="445" y="365" fill="#40b0d0" font-size="11" font-family="monospace" text-anchor="middle">right eye pixels</text>
+  <text x="445" y="382" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">painting clipped to right half</text>
+  <text x="320" y="428" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">ONE shared frame buffer &#8594; one blit to screen (side-by-side image)</text>
+
+  <line x1="250" y1="288" x2="195" y2="328" stroke="#39FF14" stroke-width="1.2" marker-end="url(#arrowG)"/>
+  <line x1="390" y1="288" x2="445" y2="328" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#arrow)"/>
+
+  <text x="60" y="464" fill="#999" font-size="9" font-family="monospace">vertex buffers, aggregators and paint slots still cycle through 3 slots - the two passes overlap freely</text>
+</svg>
diff --git a/doc/Stereoscopic rendering/index.org b/doc/Stereoscopic rendering/index.org
new file mode 100644 (file)
index 0000000..f1ceee8
--- /dev/null
@@ -0,0 +1,190 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Stereoscopic Rendering - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-understanding-3d-engine][<- Back to index]]
+
+* What stereo rendering adds
+:PROPERTIES:
+:CUSTOM_ID: what-stereo-adds
+:END:
+
+A single rendered image is flat: the brain infers depth only from
+monocular cues (occlusion, shading, perspective, motion parallax while
+you move). *Stereoscopic rendering* adds the strongest depth cue of
+all — /binocular disparity/: your two eyes see slightly different
+images, and the visual cortex turns the difference into a direct
+sensation of depth.
+
+Aukio 3D implements the simplest and most portable form: *side-by-side
+stereo*. Every frame renders the scene twice — once from the left eye
+position, once from the right — into the left and right halves of the
+same image. A VR headset, 3D TV, or a pair of XR glasses in
+side-by-side mode feeds each half to the corresponding eye, and the
+scene gains real volume.
+
+#+CAPTION: A side-by-side stereoscopic frame of the House demo, rendered headlessly with [[file:../index.org::#snapshot][the Snapshot tool]]. Left half: left eye. Right half: right eye. Compare the dark cube and the doorway between the halves — the horizontal shift is the disparity your brain reads as depth.
+[[file:stereo-side-by-side.png]]
+
+* The geometry: two parallel cameras
+:PROPERTIES:
+:CUSTOM_ID: geometry
+:END:
+
+The two eye cameras are identical to the mono camera except for one
+thing: the left eye's position is shifted by -IPD/2 and the right
+eye's by +IPD/2 along the *world* X axis (the offset is applied to the
+camera translation's =x= component directly, then restored). IPD
+(inter-pupillary distance) defaults to *6.5 world units* — the House
+demo treats 1 unit as 1 cm, and 6.5 cm is the median human IPD.
+
+Both cameras look in exactly the same direction (*parallel cameras*,
+no toe-in). Objects at different depths then land at different
+horizontal offsets between the two images — that offset is the
+disparity:
+
+#+CAPTION: Top-down view: the two eye positions and how a near and a far object project onto the screen plane. The near object separates much more between the eyes than the far one.
+[[file:Stereo geometry.svg]]
+
+- near object -> large disparity -> feels close,
+- far object -> small disparity -> feels far,
+- object at infinity -> zero disparity.
+
+Larger IPD exaggerates disparity (stronger but potentially straining
+depth); smaller IPD flattens the scene. =+=/=-= keys adjust it live in
+0.5-unit steps while stereo is active.
+
+* One frame = two passes
+:PROPERTIES:
+:CUSTOM_ID: two-passes
+:END:
+
+Stereo does not add a second pipeline — it runs the existing
+triple-buffered pipeline *twice per frame*. The render thread in
+=ViewPanel.renderFrame()= executes two render passes back to back:
+
+#+CAPTION: Per frame, the camera is nudged left, a full transform/sort/bin pass runs for the left viewport, then the camera is nudged right and a second pass runs for the right viewport. Both paint into one shared frame buffer, clipped to their half.
+[[file:Stereo pipeline.svg]]
+
+1. *Pass LEFT:* camera translation.x is temporarily decreased by
+   IPD/2, the scene is transformed, depth-sorted and tile-binned into a
+   per-pass =RenderingContext= copy whose viewport is the left half of
+   the frame (=[0, width/2)=), and the paint continuation is submitted
+   to the worker pool.
+2. *Pass RIGHT:* the same with +IPD/2 and the right viewport
+   (=[width/2, width)=). The camera offset is always restored in a
+   =finally= block, so the camera never drifts.
+3. The two paints write into *one shared frame buffer* — each clipped
+   to its half — and the completed side-by-side image is blitted to
+   the screen in one go.
+
+The triple-buffer machinery (3 vertex slots, 3 aggregator slots, 3
+framebuffers) does not change: a *pass* takes the slot =passCounter %
+3=, so left and right passes of the same frame simply occupy
+consecutive slots and overlap exactly like consecutive mono frames do.
+Workers flow from one pass's tiles straight into the next pass's tiles
+with no idle gap.
+
+* What adapts per eye
+:PROPERTIES:
+:CUSTOM_ID: per-eye
+:END:
+
+The scene itself — geometry, textures, lightmaps, global illumination
+— is shared and identical for both eyes. Only the *viewpoint* moves,
+so only view-dependent stages differ per pass:
+
+#+CAPTION: The per-eye surface area of the engine. Everything not listed here is eye-independent.
+[[file:Stereo per eye.svg]]
+
+- *Projection:* =Vertex= projects with =projectionScale = eyeWidth/3=
+  (per-eye horizontal FOV) and adds =stereoViewportOffsetX= so the
+  projected image lands in the correct half of the buffer. The same
+  offset is applied for near-plane-clip vertices created directly in
+  camera space.
+- *Frustum culling:* the frustum is rebuilt per pass from
+  =stereoViewportWidth=, so each eye culls against its own (narrower)
+  view volume — nothing leaks in from the other eye's half.
+- *Painting:* every painter clips X to =[renderMinX, renderMaxX)=,
+  which the pass set to its viewport. No eye can paint into the other
+  half, even if a polygon crosses the center line.
+- *Mouse picking:* in stereo each eye shows the same object at a
+  different screen X, so a hit can only be resolved against one eye.
+  =ViewPanel= combines mouse results only for the pass whose viewport
+  actually contains the cursor.
+- *HUD/overlays:* developer tools, crosshair and text are drawn once
+  over the finished frame, at zero disparity — they sit on the screen
+  surface, not in the world.
+
+* Enabling stereo
+:PROPERTIES:
+:CUSTOM_ID: enabling
+:END:
+
+#+BEGIN_SRC java
+ViewPanel viewPanel = ...;
+
+// Side-by-side stereo on:
+viewPanel.setStereoModeEnabled(true);
+
+// Optional: match the viewer (default 6.5 world units):
+viewPanel.setStereoIPD(6.5);
+#+END_SRC
+
+In the demos, *SHIFT+F11* toggles stereo and fullscreen together (XR
+glasses want both); plain *F11* remains fullscreen-only. With stereo
+active, *+* and *-* adjust the IPD in 0.5-unit steps (clamped at 0.5)
+so the viewer can tune comfort at runtime.
+
+#+CAPTION: The same scene rendered as a normal mono frame (for comparison with the pair above). Notice there is no horizontal offset to read depth from — the picture is flat.
+[[file:mono-comparison.png]]
+
+* Performance and limitations
+:PROPERTIES:
+:CUSTOM_ID: limitations
+:END:
+
+- Stereo *doubles the per-frame transform, sort and paint work* — two
+  full passes instead of one. The pipeline overlaps them the same way
+  it overlaps consecutive mono frames, so throughput drops less than
+  2x on a multi-core machine, but expect a real cost.
+- Each eye gets *half the horizontal resolution* of the panel. On a
+  1920x1080 fullscreen window each eye sees 960x1080 — pixels are
+  shared, not duplicated.
+- IPD is in *world units*: 6.5 only means "6.5 cm" if the scene is
+  modeled at 1 unit = 1 cm. In a scene with a different scale, divide
+  or multiply accordingly — or just tune with =+=/=-= until the depth
+  feels right.
+- The eye offset is applied along the *world X axis*, not the camera's
+  right vector: it is exactly correct when the camera faces along Z
+  (yaw = 0) and degrades as you turn — at yaw = 90° the eyes would be
+  offset front-to-back instead of side-to-side. For a fixed-viewing-
+  direction demo this is fine; a fully rotational stereo camera would
+  need to apply the IPD along the rotated right vector.
+- Side-by-side is a *display format*, not a headset driver: the engine
+  produces the image; an XR viewer, 3D TV or video player is
+  responsible for delivering the halves to the eyes.
+- Global illumination is unaffected: lightmaps live on the surfaces,
+  so both eyes sample the same converged lighting for free.
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class                | Role in stereo rendering                                        |
+|----------------------+-----------------------------------------------------------------|
+| =ViewPanel=          | owns stereoModeEnabled/stereoIPD; runs the two passes per frame |
+| =StereoEye=          | NONE / LEFT / RIGHT tag carried by each pass context            |
+| =RenderingContext=   | per-eye viewport fields: stereoViewportWidth/OffsetX, renderMin/MaxX |
+| =Vertex=             | per-eye projection: scale from eye width + viewport X offset    |
+| =ShapeCollection=    | rebuilds the frustum per pass from the eye's viewport width     |
+| =InputManager=       | SHIFT+F11 stereo toggle, +/- live IPD adjustment                |
+
+[[file:../index.html#outline-container-understanding-3d-engine][Back to main documentation]]
diff --git a/doc/Stereoscopic rendering/mono-comparison.png b/doc/Stereoscopic rendering/mono-comparison.png
new file mode 100644 (file)
index 0000000..3b70ee0
Binary files /dev/null and b/doc/Stereoscopic rendering/mono-comparison.png differ
diff --git a/doc/Stereoscopic rendering/stereo-side-by-side.png b/doc/Stereoscopic rendering/stereo-side-by-side.png
new file mode 100644 (file)
index 0000000..12fa4fc
Binary files /dev/null and b/doc/Stereoscopic rendering/stereo-side-by-side.png differ
diff --git a/doc/Winding order.svg b/doc/Winding order.svg
new file mode 100644 (file)
index 0000000..d82048e
--- /dev/null
@@ -0,0 +1,35 @@
+<svg viewBox="0 0 640 480" width="640" height="480" xmlns="http://www.w3.org/2000/svg">
+  <defs>
+    <marker id="arrow-green" viewBox="0 0 10 10" refX="10" refY="5"
+            markerWidth="8" markerHeight="8" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="#30a050"/>
+    </marker>
+    <marker id="arrow-red" viewBox="0 0 10 10" refX="10" refY="5"
+            markerWidth="8" markerHeight="8" orient="auto-start-reverse">
+      <path d="M 0 0 L 10 5 L 0 10 z" fill="rgba(208,64,64,0.5)"/>
+    </marker>
+  </defs>
+  <rect width="640" height="480" fill="#061018"/>
+
+  <!-- Green front-face triangle: V1=top, V2=bottom-left, V3=bottom-right -->
+  <polygon points="160,100 260,360 60,360" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="3"/>
+  <!-- CCW arrow: arc from near V1, curves LEFT and DOWN toward V2 -->
+  <path d="M140,144 A 104,104 0 0,0 74,310" fill="none" stroke="#30a050" stroke-width="3" stroke-dasharray="8 4" marker-end="url(#arrow-green)"/>
+  <text x="68" y="240" fill="#30a050" font-size="20" font-weight="700" font-family="monospace">CCW</text>
+  <circle cx="160" cy="100" r="6" fill="#30a050"/>
+  <circle cx="60" cy="360" r="6" fill="#30a050"/>
+  <circle cx="260" cy="360" r="6" fill="#30a050"/>
+  <text x="156" y="88" fill="#aaa" font-size="18" font-family="monospace">V₁</text>
+  <text x="28" y="396" fill="#aaa" font-size="18" font-family="monospace">V₂</text>
+  <text x="264" y="396" fill="#aaa" font-size="18" font-family="monospace">V₃</text>
+  <text x="72" y="440" fill="#30a050" font-size="22" font-weight="700" font-family="monospace">FRONT FACE ✓</text>
+  <!-- Red back-face triangle -->
+  <polygon points="480,100 580,360 380,360" fill="rgba(208,64,64,0.06)" stroke="rgba(208,64,64,0.3)" stroke-width="3" stroke-dasharray="12 6"/>
+  <!-- CW arrow: arc from near V1, curves RIGHT and DOWN -->
+  <path d="M500,144 A 104,104 0 0,1 566,310" fill="none" stroke="rgba(208,64,64,0.5)" stroke-width="3" stroke-dasharray="8 4" marker-end="url(#arrow-red)"/>
+  <text x="536" y="240" fill="rgba(208,64,64,0.6)" font-size="20" font-weight="700" font-family="monospace">CW</text>
+  <line x1="456" y1="216" x2="504" y2="264" stroke="rgba(208,64,64,0.4)" stroke-width="6"/>
+  <line x1="504" y1="216" x2="456" y2="264" stroke="rgba(208,64,64,0.4)" stroke-width="6"/>
+  <text x="372" y="440" fill="rgba(208,64,64,0.7)" font-size="22" font-weight="700" font-family="monospace">BACK FACE ✗</text>
+  <text x="390" y="468" fill="#aaa" font-size="18" font-family="monospace">(culled — not drawn)</text>
+</svg>
diff --git a/doc/export-docs.sh b/doc/export-docs.sh
new file mode 100755 (executable)
index 0000000..d55e8f2
--- /dev/null
@@ -0,0 +1,45 @@
+#!/bin/bash
+# export-docs.sh — export all org-mode documentation pages to HTML.
+#
+# Exports every doc/**/index.org (and doc/index.org) with the darksun
+# theme, using the user's Emacs configuration. Run from anywhere:
+#
+#   doc/export-docs.sh            # export all pages
+#   doc/export-docs.sh --check    # export, then render every page with
+#                                 # headless Chrome to /tmp/doc-check-*.png
+#                                 # for visual inspection
+#
+# Requires: emacs (with ~/.emacs providing the org HTML setup),
+#           google-chrome (only for --check).
+
+set -euo pipefail
+DOC_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+mapfile -t PAGES < <(find "$DOC_DIR" -name index.org | sort)
+
+echo "Exporting ${#PAGES[@]} pages..."
+for page in "${PAGES[@]}"; do
+    rel="${page#"$DOC_DIR"/}"
+    if emacs --batch -l ~/.emacs --visit="$page" \
+            --funcall=org-html-export-to-html --kill 2>&1 \
+            | grep -qi "aborted\|unable to resolve link"; then
+        echo "FAIL $rel"
+        exit 1
+    fi
+    echo "  ok $rel"
+done
+
+if [[ "${1:-}" == "--check" ]]; then
+    echo "Rendering pages for visual check..."
+    for page in "${PAGES[@]}"; do
+        rel="${page#"$DOC_DIR"/}"
+        html="${page%.org}.html"
+        out="/tmp/doc-check-$(echo "$rel" | tr '/ ' '__').png"
+        google-chrome --headless --disable-gpu --hide-scrollbars \
+            --virtual-time-budget=8000 --window-size=1100,2000 \
+            --screenshot="$out" "file://$html" 2>/dev/null
+        echo "  shot $out"
+    done
+fi
+
+echo "Done."
diff --git a/doc/index.org b/doc/index.org
new file mode 100644 (file)
index 0000000..4068d09
--- /dev/null
@@ -0,0 +1,1224 @@
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Aukio 3D - Realtime 3D engine
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+#+HTML_HEAD: <link rel="stylesheet" href="style.css"/>
+
+* Introduction
+:PROPERTIES:
+:CUSTOM_ID: overview
+:ID:       a31a1f4d-5368-4fd9-aaf8-fa6d81851187
+:END:
+
+[[file:Example.png]]
+
+*Aukio 3D* is a realtime 3D rendering engine written in pure Java. It
+runs entirely on the CPU — no GPU required, no OpenGL, no Vulkan, no
+native libraries. Just Java.
+
+The motivation is simple: GPU-based 3D is a minefield of accidental
+complexity. Drivers are buggy or missing entirely. Features you need
+aren't supported on your target hardware. You run out of GPU RAM. You
+wrestle with platform-specific interop layers, shader compilation
+quirks, and dependency hell. Every GPU API comes with its own
+ecosystem of pain — version mismatches, incomplete implementations,
+vendor-specific workarounds. I want a library that "just works".
+
+*Aukio 3D* takes a different path. By rendering everything in software
+on the CPU, the entire GPU problem space simply disappears. You add a
+Maven dependency, write some Java, and you have a 3D scene. It runs
+wherever Java runs.
+
+This approach is quite practical for many use-cases. Modern systems
+ship with many CPU cores, and those with unified memory architectures
+offer high bandwidth between CPU and RAM. Software rendering that once
+seemed wasteful is now a reasonable choice where you need good-enough
+performance without the overhead of a full GPU pipeline. Java's JIT
+compiler helps too, optimizing hot rendering paths at runtime.
+
+Beyond convenience, CPU rendering gives you complete control. You own
+every pixel. You can freely experiment with custom rendering
+algorithms, optimization strategies, and visual effects without being
+constrained by what a GPU API exposes. Instead of brute-forcing
+everything through a fixed GPU pipeline, you can implement clever,
+application-specific optimizations.
+
+*Aukio 3D* is part of the larger [[https://www3.svjatoslav.eu/projects/aukio/][Aukio project]], with the long-term goal
+of providing a platform for 3D user interfaces and interactive data
+visualization. It can also be used as a standalone 3D engine in any
+Java project. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/][demos]] for examples of what it can do today.
+
+*Major features:*
+** Global Illumination
+:PROPERTIES:
+:CUSTOM_ID: global-illumination
+:END:
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:Global illumination/Global illumination.png]]
+
+On top of flat shading, the engine computes progressive *global
+illumination* on background CPU threads: real shadows, smooth light
+falloff inside polygons (per-texel lightmaps), and indirect bounce
+light — while the render loop itself never traces a single ray.
+
+Read more about [[file:Global illumination/][global illumination]].
+
+** Side-by-side stereoscopic rendering support
+:PROPERTIES:
+:CUSTOM_ID: stereoscopic
+:END:
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:Stereoscopic rendering/stereo-side-by-side.png]]
+
+The engine can render every frame twice — once per eye — into the left
+and right halves of the same image, for XR glasses and 3D displays.
+The cameras stay parallel and are offset by a configurable IPD
+(inter-pupillary distance). Two render passes share one triple-buffered
+pipeline, each clipped to its half of the frame buffer; per-eye
+projection, frustum culling and mouse picking adapt automatically.
+
+See [[file:Stereoscopic%20rendering/][Stereoscopic rendering]] for the
+geometry, pipeline and tuning.
+
+** Constructive Solid Geometry
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:CSG/CSG demo.png]]
+
+*Aukio 3D* allows performing boolean operations against geometry shapes.
+So one can subtract, unionize or intersect shapes.
+
+To understand CSG boolean operations, read more about [[file:CSG/][Constructive
+Solid Geometry]].
+
+** SDF textures for sharp text
+:PROPERTIES:
+:CUSTOM_ID: sdf-text
+:END:
+
+[[file:SDF textures/sdf-angled.png]]
+
+Text and vector-art surfaces do not store coverage; they store a
+*signed distance field* — per texel, the distance to the nearest glyph
+edge. The rasterizer re-derives coverage per screen pixel from that
+smooth field, so text stays sharp at any zoom and fades to clean gray
+under minification, all without a mipmap chain.
+
+See [[file:SDF%20textures/][SDF textures]] for the glyph pipeline, the
+render path, analytic minification and tuning knobs.
+
+* How take engine into use
+:PROPERTIES:
+:CUSTOM_ID: taking-engine-into-use
+:END:
+
+Add the *Aukio 3D* dependency to your Maven project:
+
+#+BEGIN_SRC xml
+<dependencies>
+    <dependency>
+        <groupId>eu.svjatoslav</groupId>
+        <artifactId>aukio-3d</artifactId>
+        <version>1.4</version>
+    </dependency>
+</dependencies>
+#+END_SRC
+
+Also add the repository (the library is not on Maven Central):
+
+#+BEGIN_SRC xml
+<repositories>
+    <repository>
+        <id>svjatoslav.eu</id>
+        <name>Svjatoslav repository</name>
+        <url>https://www3.svjatoslav.eu/maven/</url>
+    </repository>
+</repositories>
+#+END_SRC
+
+- Library requires Java 21 or newer.
+
+- Study the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/][demo applications]] for practical examples. Start with the
+  [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#minimal-example][minimal example]] to see the basic boilerplate needed to render a 3D
+  scene.
+
+- Study [[#understanding-3d-engine][how Aukio 3D engine works]].
+- Read online [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/][JavaDoc]].
+- See [[https://www3.svjatoslav.eu/projects/aukio-3d/graphs/][*Aukio 3D* class diagrams]]. (Diagrams were generated by using
+  [[https://www3.svjatoslav.eu/projects/javainspect/][JavaInspect]] utility)
+
+* Essential theory
+:PROPERTIES:
+:CUSTOM_ID: understanding-3d-engine
+:ID:       4b6c1355-0afe-40c6-86c3-14bf8a11a8d0
+:END:
+** Coordinate System (X, Y, Z)
+:PROPERTIES:
+:CUSTOM_ID: coordinate-system
+:END:
+
+#+INCLUDE: "Coordinate system.svg" export html
+
+*Aukio 3D* uses a **left-handed coordinate system with X pointing right
+and Y pointing down**, matching standard 2D screen coordinates. This
+coordinate system should feel intuitive for people with preexisting 2D
+graphics background.
+
+| Axis | Direction                          | Meaning                                   |
+|------+------------------------------------+-------------------------------------------|
+| X    | Horizontal, positive = RIGHT       | Objects with larger X appear to the right |
+| Y    | Vertical, positive = DOWN          | Lower Y = higher visually (up)            |
+| Z    | Depth, positive = away from viewer | Negative Z = closer to camera             |
+
+*Practical Examples*
+
+- A point at =(0, 0, 0)= is at the origin.
+- A point at =(100, 50, 200)= is: 100 units right, 50 units down
+  visually, 200 units away from the camera.
+- To place object A "above" object B, give A a **smaller Y value**
+  than B.
+
+Coordinates in this system are stored using the
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point3D.html][Point3D]] class — a mutable container with public =x=, =y=, =z= fields
+supporting vector operations like distance, rotation, and translation.
+Vertices (see [[#vertex][below]]) are positioned within this coordinate system.
+
+The [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#coordinate-system][aukio-3d-demos]] project includes an interactive
+coordinate system reference showing X, Y, Z axes as colored arrows
+with a grid plane for spatial context.
+
+** Point3D and Vertex
+:PROPERTIES:
+:CUSTOM_ID: vertex
+:END:
+
+#+INCLUDE: "Point3D vertex.svg" export html
+
+Every 3D object is built from *vertices* — corner points that define
+the shape's geometry. A triangle has 3 vertices, a cube has 8, and
+complex meshes have thousands. The engine uses two related classes to
+represent points in 3D space, each serving a different purpose.
+
+
+
+*** Point3D — Raw Coordinates
+
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point3D.html][Point3D]] is the fundamental coordinate type throughout the engine. It
+stores a position or vector with three public fields: =x=, =y=, =z=.
+The class provides vector math operations: distance calculation,
+rotation, translation, scaling, dot/cross products, and interpolation. Methods follow a fluent API convention where mutating
+operations (like =add=, =multiply=) return =this= for chaining, while
+non-mutating variants (like =withAdded=, =withMultiplied=) return new
+instances.
+
+Use =Point3D= for:
+- Storing positions, vectors, or any raw 3D coordinate
+- Distance and angle calculations between points
+- Vector math (dot product, cross product, normalization)
+- Rotating or translating positions before shape construction
+
+#+BEGIN_SRC java
+Point3D p1 = new Point3D(100, 50, 200);
+Point3D p2 = new Point3D(0, 0, 100);
+double distance = p1.getDistanceTo(p2);                  // Euclidean distance
+Point3D direction = p1.withSubtracted(p2).unit();        // New point: unit vector from p2 to p1
+p1.rotate(new Point3D(0,0,0), Math.PI/4, 0);             // Rotate p1 in place, 45° in XZ plane
+#+END_SRC
+
+*** Vertex — Rendering-Ready Coordinates
+
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]] wraps a =Point3D= and adds the coordinate spaces needed during
+rendering. As a shape transforms through the render pipeline, each
+vertex tracks its position in multiple spaces:
+
+| Field                  | Purpose                                                |
+|------------------------+--------------------------------------------------------|
+| =coordinate=           | Original position in local/model space                 |
+| =transformedCoordinate(ctx)=  | Position relative to camera (after transform stack) |
+| =onScreenCoordinate(ctx)=     | 2D screen pixels (after perspective projection)     |
+| =textureCoordinate=    | Optional UV coords in pixel units (not normalized)     |
+| =normal=               | Optional normal vector for CSG polygon splitting       |
+
+=transformedCoordinate= and =onScreenCoordinate= are accessor methods,
+not plain fields: each vertex carries three slots for each, one per
+pipeline projection slot, and the accessor picks the slot of the
+context's current render pass. This is what lets the triple-buffered
+pipeline transform the next frame while previous frames are still
+being painted (see [[file:Rendering loop/index.org::#software-pipeline][software pipeline]]).
+
+During rendering, the vertex is transformed through all spaces: first
+applying the
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/TransformStack.html][TransformStack]] to get the camera-relative coordinate, then
+projecting to 2D. Results are cached per frame per slot to avoid
+recomputing for vertices shared across multiple shapes.
+
+Use =Vertex= when:
+- Constructing triangles, polygons, or textured shapes
+- Your geometry needs texture UV coordinates
+- You're performing CSG boolean operations (requires =normal=)
+
+#+BEGIN_SRC java
+// Create a textured triangle (texture coordinates use pixel units)
+// For a 256x256 texture: (0,0)=top-left, (256,256)=bottom-right
+Vertex v1 = new Vertex(new Point3D(0, 0, 100), new Point2D(0, 0));
+Vertex v2 = new Vertex(new Point3D(100, 0, 100), new Point2D(256, 0));
+Vertex v3 = new Vertex(new Point3D(50, 100, 100), new Point2D(128, 256));
+TexturedTriangle triangle = new TexturedTriangle(v1, v2, v3, texture);
+#+END_SRC
+
+*** When to Use Each
+
+| Use Point3D                          | Use Vertex                                    |
+|--------------------------------------+-----------------------------------------------|
+| Positioning shapes, cameras, lights  | Building triangles and polygons               |
+| Vector math (distances, directions)  | Texture-mapped geometry                      |
+| Rotating or translating positions    | CSG operations                                |
+| Temporary calculations               | Shapes that render through transform pipeline |
+
+For simple shapes without textures, you can pass raw =Point3D=
+coordinates directly to constructors — the shape will internally wrap
+them in =Vertex= objects. The [[#coordinate-system][coordinate system]] above defines the
+meaning of all =x=, =y=, =z= values in both classes.
+
+** Edge
+:PROPERTIES:
+:CUSTOM_ID: edge
+:END:
+
+#+INCLUDE: "Edge.svg" export html
+
+An *edge* is a straight line segment connecting two [[#vertex][vertices]]. Edges
+form the wireframe skeleton of a 3D model — the structural framework
+visible when surfaces are not rendered. A triangle has 3 edges, a cube
+has 12 edges, and complex meshes have thousands.
+
+In *Aukio 3D*, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class implements edges as renderable shapes. Each
+Line connects two [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html][Vertex]] endpoints and stores two properties: a
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html#width][width]] in world units (adjusted for perspective during rendering) and a
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html#color][color]] with alpha transparency. The rendering algorithm switches
+between two modes based on the projected screen width: thin lines below
+the threshold are drawn as single pixels with alpha-adjusted coloring,
+while thicker lines are rendered as filled rectangles with perspective-correct
+edge fading using four [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.html][LineInterpolator]] scanline boundaries.
+
+Wireframe shapes are composite objects built from multiple Line instances.
+For example, [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.html][WireframeBox]] creates 12 Line objects — four edges parallel to
+each axis — using a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.html][LineAppearance]] factory to ensure consistent styling across
+all edges. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.html][WireframeCube]] convenience subclass provides a center-point
+constructor. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] for a visual comparison of
+wireframe (edges only) versus solid polygon (surfaces with lighting)
+rendering modes.
+
+** Face (Triangle)
+:PROPERTIES:
+:CUSTOM_ID: face-triangle
+:END:
+
+#+INCLUDE: "Face triangle.svg" export html
+
+A *face* is a flat surface enclosed by edges — the visible skin of a 3D
+object. While faces can theoretically have any number of sides, 3D
+engines standardize on *triangles* because three points always define a
+flat plane. A quad (4 vertices) or pentagon (5 vertices) might be
+non-planar depending on vertex positions, causing rendering artifacts.
+Triangles avoid this problem entirely.
+
+*** SolidPolygon — Solid-Color Faces
+
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] is the primary face type, supporting any number of vertices
+(3 or more). Triangles render directly via scanline rasterization.
+N-vertex polygons (quads, pentagons, etc.) are triangulated using fan
+decomposition — a quad becomes 2 triangles, a pentagon 3 — but only
+when the polygon lives inside an
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]]
+(the scene graph root is one): the composite triangulates while
+building its render list. A standalone SolidPolygon with more than 3
+vertices cannot be painted directly and throws IllegalStateException.
+
+Each SolidPolygon stores a single fill color with optional alpha
+transparency. When shading is enabled, the lighting manager computes
+the polygon's illumination once during the transform phase, then
+applies the shaded color during painting. Backface culling (see
+[[#winding-order-backface-culling][Winding Order & Backface Culling]]) can be enabled per-polygon, or
+applied recursively to an entire composite shape via
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setBackfaceCulling(boolean)][AbstractCompositeShape.setBackfaceCulling(true)]] — this propagates the
+setting to all SolidPolygon and TexturedTriangle sub-shapes, including
+nested composites.
+
+#+BEGIN_SRC java
+// Create a red triangle
+SolidPolygon triangle = SolidPolygon.triangle(
+    new Point3D(0, 0, 100),
+    new Point3D(50, 0, 100),
+    new Point3D(25, 50, 100),
+    Color.RED
+);
+
+// Create a blue quad (internally triangulated)
+SolidPolygon quad = SolidPolygon.quad(
+    new Point3D(-50, -50, 100),
+    new Point3D(50, -50, 100),
+    new Point3D(50, 50, 100),
+    new Point3D(-50, 50, 100),
+    Color.BLUE
+);
+
+// Enable lighting and culling for a closed mesh
+quad.setShadingEnabled(true);
+quad.setBackfaceCulling(true);
+
+// Add to the scene — the root composite triangulates the quad
+viewPanel.getRootShapeCollection().addShape(quad);
+#+END_SRC
+
+*** TexturedTriangle — UV-Mapped Faces
+
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] renders faces with image textures mapped via UV
+coordinates. Each of the three [[#vertex][vertices]] stores a =textureCoordinate=
+(a [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Point2D.html][Point2D]] with U and V values in *pixel units* matching the texture
+dimensions). For a 256×256 texture, coordinates range from (0,0) at the
+top-left corner to (256,256) at the bottom-right. During rasterization, the
+engine interpolates these UV coordinates across the triangle's surface,
+sampling the texture at each pixel. When mipmaps are used, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html#multiplicationFactor][multiplicationFactor]]
+scales coordinates to match the selected mipmap resolution.
+
+The texture system supports mipmaps — pre-scaled versions of the texture
+selected based on the triangle's screen size to reduce aliasing artifacts
+on distant surfaces. Texture coordinates are mapped with
+perspective-correct interpolation inside the scanline rasterizer, so
+large triangles at steep angles render without distortion — see the
+[[file:Perspective correct textures/][perspective-correct textures]] page.
+
+#+BEGIN_SRC java
+// Create a 256x256 texture
+Texture texture = new Texture(256, 256, 2);  // width, height, maxUpscale
+
+// Create a textured triangle with UV coordinates in pixel units
+Vertex v1 = new Vertex(new Point3D(0, 0, 100), new Point2D(0, 0));      // top-left
+Vertex v2 = new Vertex(new Point3D(100, 0, 100), new Point2D(256, 0));  // top-right
+Vertex v3 = new Vertex(new Point3D(50, 100, 100), new Point2D(128, 256)); // bottom-center
+
+TexturedTriangle triangle = new TexturedTriangle(v1, v2, v3, texture);
+triangle.setBackfaceCulling(true);
+#+END_SRC
+
+Both SolidPolygon and TexturedTriangle extend
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]], which handles vertex transformation and depth
+sorting. See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] for a visual comparison of solid
+versus textured polygon rendering.
+
+** Normal Vector
+:PROPERTIES:
+:CUSTOM_ID: normal-vector
+:END:
+
+#+INCLUDE: "Normal vector.svg" export html
+
+A *normal* is a vector perpendicular to a surface. It tells the
+renderer which direction a face is pointing. Normals are critical for
+*lighting* — the angle between the light direction and the normal
+determines how bright a surface appears.
+
+**Use cases:**
+
+| Use case             | API                                          | Computation                | Location          |
+|----------------------+----------------------------------------------+----------------------------+-------------------|
+| BSP/CSG operations   | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#getPlane()][SolidPolygon.getPlane()]] → [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#normal][Plane.normal]]       | Lazy-cached once           | =Plane=           |
+| Per-frame shading    | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]] → =cachedNormal= field | Recomputed every frame     | =SolidPolygon=    |
+| Lighting calculation | [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html#computeLighting()][LightingManager.computeLighting()]]            | Uses normal via =dot(L,N)= | =LightingManager= |
+
+**Implementation notes:**
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]]: shared zero-allocation helper for computing normals from three points
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Plane]]: stores normals in Hesse normal form (normal + distance) for BSP spatial partitioning
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Vertex.html#normal][Vertex.normal]]: optional field for CSG polygon splitting (not used for rendering)
+
+** Mesh
+:PROPERTIES:
+:CUSTOM_ID: mesh
+:END:
+
+#+INCLUDE: "Mesh.svg" export html
+
+A *mesh* is a collection of vertices, edges, and faces that together
+define the shape of a 3D object. Even curved surfaces like spheres are
+approximated by many small triangles — more triangles means a smoother
+appearance. A cube has 8 vertices forming 12 triangular faces, while a
+smooth sphere requires hundreds or thousands of triangles depending on
+the desired quality.
+
+In *Aukio 3D*, meshes are built through composition rather than
+monolithic vertex/index buffers. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]] class is
+the foundation for primitive shapes — each instance stores its own
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.html#vertices][List&lt;Vertex&gt;]] directly. This includes [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] (N-vertex
+convex polygons, not limited to triangles), [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]]
+(UV-mapped triangles), and [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] (wireframe edges). The
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] class groups multiple shapes into a single
+object with its own position, rotation, and transform — useful for
+complex models that move or rotate together.
+
+Complex meshes are constructed procedurally by adding primitive shapes
+during initialization. For example, [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.html][SolidPolygonSphere]] generates
+triangles using a latitude-longitude grid: with 16 segments, it
+creates 960 SolidPolygon triangles by looping through
+rings and sectors, calling [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#addShape(eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape)][addShape()]] for each. The generic
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.html][SolidPolygonMesh]] accepts any list of triangles, allowing custom
+geometry from procedural generation or external sources.
+
+During rendering, several automatic optimizations occur. N-vertex
+polygons (quads, pentagons, etc.) are triangulated using fan
+triangulation inside the composite's render-list builder, converting
+an N-vertex polygon into N-2 triangles. Textured triangles render with
+perspective-correct texture mapping (see [[file:Perspective correct textures/][perspective-correct textures]]).
+Composites perform view frustum culling to skip rendering when entirely
+off-screen. Sub-shapes can be organized into named groups via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.html][SubShape]]
+wrappers, allowing [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#showGroup(java.lang.String)][showGroup()]] and [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#hideGroup(java.lang.String)][hideGroup()]] to toggle visibility of
+entire sections. Composite shapes also support CSG boolean operations
+— see the [[file:CSG/][Constructive Solid Geometry]] documentation for union,
+subtract, and intersect operations.
+
+The [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#shape-gallery][Shape Gallery demo]] showcases all primitive shapes available in
+*Aukio 3D*, rendered in both wireframe mode (edges only via
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.html][WireframeBox]] and similar) and solid polygon mode (filled surfaces with
+dynamic lighting).
+
+** Working with Colors
+:PROPERTIES:
+:CUSTOM_ID: working-with-colors
+:ID:       f2c9642a-a093-444f-8992-76c97ff28c16
+:END:
+
+Aukio 3D uses its own [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html][Color class]] instead of [[https://docs.oracle.com/en/java/javase/21/docs/api/java.desktop/java/awt/Color.html][java.awt.Color]]. This
+custom implementation is designed specifically for the engine's
+software rasterizer, where avoiding object allocation during rendering
+is critical for performance. When rendering thousands of polygons per
+frame, creating new Color instances for each one would generate
+excessive garbage and trigger frequent garbage collection
+pauses. Instead, the engine's Color class uses mutable fields that can
+be reused across frames.
+
+The class stores RGBA components as public integer fields in the range
+0–255. This format matches the engine's pixel buffer layout and avoids
+costly float-to-int conversions during rasterization. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#r][r]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#g][g]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#b][b]], and
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#a][a]] fields are accessible directly, allowing lighting calculations and
+alpha blending to modify colors in-place without allocating new
+objects. For example, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] class maintains a reusable
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][shadedColor]] field that gets updated during each frame's lighting
+calculation instead of creating a new Color instance per polygon.
+
+Color provides several constructors for different input formats. The
+most common approach is using hex strings via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#hex(java.lang.String)][Color.hex(String)]] or the
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(java.lang.String)][String constructor]], which support formats like ="F80"= (3-digit RGB),
+="FF8800"= (6-digit RGB), ="F808"= (4-digit RGBA), and ="FF8800CC"=
+(8-digit RGBA). You can also create colors from integer RGBA
+components (0–255) using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(int,int,int,int)][new Color(r, g, b, a)]], from floating-point
+components (0.0–1.0) via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(double,double,double,double)][new Color(double r, double g, double b,
+double a)]], or from a packed RGB integer like =0xFF8800= using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#%3Cinit%3E(int)][new
+Color(int rgb)]]. The class also provides predefined constants for
+common colors: [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#RED][Color.RED]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#GREEN][Color.GREEN]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#BLUE][Color.BLUE]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#YELLOW][Color.YELLOW]],
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#CYAN][Color.CYAN]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#MAGENTA][Color.MAGENTA]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#WHITE][Color.WHITE]], [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#BLACK][Color.BLACK]], and
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#TRANSPARENT][Color.TRANSPARENT]].
+
+The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#set(int,int,int,int)][set(int r, int g, int b, int a)]] method modifies a Color in-place
+and returns =this= for method chaining, which is essential for
+performance during rendering. For example, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]]
+calculates lighting contributions from all light sources and stores
+the final shaded color directly into a reusable Color instance via
+=set()=, avoiding any allocation. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#toAwtColor()][toAwtColor()]] method converts a
+Aukio 3D Color to a java.awt.Color when needed for Java2D graphics
+operations, caching the result to avoid repeated conversion. The
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#toInt()][toInt()]] method packs the color into an ARGB integer suitable for the
+engine's pixel buffer, used during rasterization to write pixels
+directly.
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.hex;
+
+// Using predefined color constants
+Color red = Color.RED;
+Color transparent = Color.TRANSPARENT;
+
+// Create from hex string (recommended for clarity)
+Color orange = hex("FF8800");           // RGB, fully opaque
+Color semiTransparent = hex("FF880080"); // RGBA, 50% transparent
+
+// Create from integer components (0-255)
+Color custom = new Color(255, 128, 64, 200);
+
+// Create from packed RGB integer
+Color packed = new Color(0xFF8800);
+
+// Modify existing color in-place (no allocation)
+Color reusable = new Color();
+reusable.set(100, 200, 50, 255);
+
+// Convert to AWT color for Java2D operations
+java.awt.Color awtColor = custom.toAwtColor();
+
+// Use in lighting calculations (LightingManager modifies in-place)
+// See the Shading & Lighting documentation for details
+#+END_SRC
+
+The alpha component controls transparency during rendering. A value of
+0 makes the color fully transparent, while 255 makes it fully
+opaque. The rasterizer implements alpha blending during the paint
+phase: when drawing a semi-transparent pixel, the engine blends the
+source color with the existing background pixel proportionally based
+on the alpha value. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.html#drawPixel(int,int\[\],int)][TextureBitmap.drawPixel()]] method handles this
+blending, multiplying source colors by alpha and background colors by
+=(255 - alpha)=, then combining them. You can test whether a color is
+fully transparent using [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/Color.html#isTransparent()][isTransparent()]], which returns true when alpha
+equals zero.
+
+For lighting calculations, the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.html][LightSource]] class uses Color to
+represent the color and intensity of emitted light. Multiple light
+sources contribute to the final shaded color of each polygon, as
+described in the [[file:Shading/index.org::#shading-lighting][Shading & Lighting]] documentation. The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.html#setAmbientLight(eu.svjatoslav.aukio.e3d.renderer.raster.Color)][ambient light]]
+provides base illumination that affects all surfaces equally,
+regardless of orientation. Colors are also used for wireframe
+rendering via the [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class, where the color field determines the
+line's appearance.
+
+** Shading & Lighting
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:Shading/Shaded%20sphere.png]]
+
+
+*Aukio 3D* implements *flat shading* — one normal per polygon,
+computed from the first three vertices. Each polygon receives a single
+color based on its orientation relative to light sources.
+
+To understand lighting and shading, read more about [[file:Shading/][shading & lighting]].
+
+* 3D engine internals
+** Main render loop
+
+The rendering loop is the heart of the engine, continuously generating
+frames at a target rate (typically 60 FPS). Each frame transforms 3D
+shapes through a multi-stage pipeline before displaying them on screen.
+
+#+INCLUDE: "Rendering loop/Render pipeline.svg" export html
+
+The render loop runs on a dedicated background daemon thread managed by
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html][ViewPanel]], which can optionally sleep between frames to maintain a
+target FPS or run unlimited for benchmarking.
+
+For a detailed walkthrough of each phase with diagrams and code
+examples, see the dedicated page: [[file:Rendering loop/][Rendering loop]].
+
+** Near-Plane Clipping
+:PROPERTIES:
+:CUSTOM_ID: near-plane-clipping
+:END:
+
+Individual polygons that straddle the camera's near plane are not
+dropped wholesale: the vertex loop is clipped against the plane, new
+intersection vertices are generated with 3D-interpolated UVs and
+normals, and the clipped polygon — a triangle can become a quad,
+painted as a triangle fan — renders normally. Only polygons entirely
+behind the near plane are culled. This keeps floor and wall tiles
+visible when the camera brushes against them.
+
+#+INCLUDE: "Near plane clip/Near plane straddle.svg" export html
+
+Read more about [[file:Near plane clip/][near-plane clipping]].
+
+** Depth buffer
+:PROPERTIES:
+:CUSTOM_ID: depth-buffer
+:END:
+
+Visibility is resolved per pixel by a depth buffer: every triangle
+interpolates =1/z= across its spans and wins a pixel only where it is
+nearer than the surface already there. Opaque geometry — textured
+triangles, solid polygons — paints front-to-back with depth writes;
+translucent geometry paints back-to-front with depth tests but no
+writes, so it never occludes. Lines and billboards stay painter-ordered
+overlays by design.
+
+See [[file:Depth%20buffer/][Depth buffer]] for the full treatment.
+
+** Frustum & View Frustum Culling
+
+*Aukio 3D* implements view frustum culling.
+
+#+INCLUDE: "Frustum culling/Frustum diagram.svg" export html
+
+To understand frustum culling and object-level visibility
+optimization, read more about [[file:Frustum culling/][frustum & view frustum culling.]]
+
+** Winding Order & Backface Culling
+:PROPERTIES:
+:CUSTOM_ID: winding-order-backface-culling
+:END:
+
+#+INCLUDE: "Winding order.svg" export html
+
+The order in which a triangle's vertices are listed determines its
+*winding order*. In *Aukio 3D*, screen coordinates have Y-axis pointing
+*down*, which inverts the apparent winding direction compared to
+standard mathematical convention (Y-up). *Counter-clockwise (CCW)* in
+screen space means front-facing. *Backface culling* skips rendering
+triangles that face away from the camera — a major performance
+optimization.
+
+- CCW winding (in screen space) → front face (visible)
+- CW winding (in screen space) → back face (culled)
+- When viewing a polygon from outside: define vertices in *counter-clockwise* order as seen from the camera
+- Saves ~50% of triangle rendering
+- Implementation uses signed area: =signedArea < 0= means front-facing
+  (in Y-down screen coordinates, negative signed area corresponds to
+  visually CCW winding)
+
+In *Aukio 3D*, backface culling is *optional* and disabled by default. Enable it per-shape:
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#setBackfaceCulling(boolean)][SolidPolygon.setBackfaceCulling(true)]]
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html#setBackfaceCulling(boolean)][TexturedTriangle.setBackfaceCulling(true)]]
+- [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#setBackfaceCulling(boolean)][AbstractCompositeShape.setBackfaceCulling(true)]] (applies to all
+  sub-shapes)
+
+See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#winding-order][Winding Order demo]] for an interactive visualization.
+
+** Perspective correct textures
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Perspective correct textures/Affine distortion.png]]
+
+*Aukio 3D* tries to do perspective-correct texture rendering. Read more
+about [[file:Perspective correct textures/][perspective-correct texture implementation]].
+
+* Developer tools
+:PROPERTIES:
+:CUSTOM_ID: developer-tools
+:ID:       8c5e2a1f-9d3b-4f6a-b8e7-1c4d5f7a9b2e
+:END:
+
+Press *F12* anywhere in the application to open the Developer Tools
+panel:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Developer tools/Developer tools.png]]
+
+This debugging interface helps you understand what the engine is doing
+internally and diagnose rendering issues. Pressing F12 again closes
+the panel.
+
+** Diagnostic toggles
+:PROPERTIES:
+:CUSTOM_ID: diagnostic-toggles
+:END:
+
+*** Show polygon borders
+:PROPERTIES:
+:CUSTOM_ID: show-polygon-borders
+:END:
+
+When enabled, each [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] draws yellow outlines around its
+three edges after rendering its texture content. This overlays the
+triangle mesh onto the final image:
+
+#+attr_html: :class responsive-img
+[[file:Developer tools/Render polygon borders.png]]
+
+Use this visualization when investigating:
+
+- Mesh structure: see the actual triangles as the rasterizer receives
+  them
+- Geometry bugs: spot T-junction gaps and overlapping geometry
+- Texture distortion: compare triangle shapes against visible warping
+
+*** Render alternate segments (overdraw debug)
+:PROPERTIES:
+:CUSTOM_ID: render-alternate-segments
+:END:
+
+Renders only even-numbered paint tiles while leaving odd-numbered ones
+black. (The screen is divided into a grid of rectangular tiles for
+parallel rendering — see [[file:Rendering loop/index.org::#phase-4-clear-paint-tiles][the rendering loop documentation]].
+"Segments" is the older name for tiles.)
+
+#+attr_html: :class responsive-img
+[[file:Developer tools/Render alternative segments.png]]
+
+This toggle helps detect overdraw: threads writing outside their
+allocated tile. If you see rendering artifacts in the black tiles, a
+paint task is writing pixels outside its assigned area — a clear sign
+of a bug.
+
+*** Show segment boundaries
+:PROPERTIES:
+:CUSTOM_ID: show-segment-boundaries
+:END:
+
+Draws red lines along the paint tile boundaries, making it easy to see
+exactly where each tile's rendered area begins and ends. In stereo
+mode each eye's viewport gets its own grid:
+
+#+attr_html: :class responsive-img
+[[file:Developer tools/Show segment boundaries.png]]
+
+Useful for:
+
+- Verifying the tile grid division
+- Debugging tile-boundary rendering issues (e.g. clipped text or
+  missing slivers at tile edges)
+- Understanding the parallel rendering architecture visually
+
+** Camera position
+:PROPERTIES:
+:CUSTOM_ID: camera-position
+:END:
+
+Displays the current camera coordinates and orientation in real-time:
+
+| Parameter | Description                              |
+|-----------+------------------------------------------|
+| x, y, z   | Camera position in 3D world space        |
+| yaw       | Rotation around the Y axis (left/right)  |
+| pitch     | Rotation around the X axis (up/down)     |
+| roll      | Rotation around the Z axis (tilt)        |
+
+The *Copy* button copies the full camera position string to the
+clipboard in a format ready to paste into bug reports or configuration
+files.
+
+Use this for:
+- Reporting exact camera positions when filing bugs
+- Saving interesting viewpoints for later reference
+- Understanding camera movement during navigation
+- Sharing specific views with other developers
+
+Example copied format:
+#+BEGIN_EXAMPLE
+500.00, -300.00, -800.00, 0.60, -0.50, -0.00
+#+END_EXAMPLE
+
+The six numbers map 1:1 onto
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/math/Transform.html#set(double,double,double,double,double,double)][Transform.set(x, y, z, yaw, pitch, roll)]],
+so a copied viewpoint can be restored at startup — this is how the
+demo applications freeze a good camera position into code:
+
+#+BEGIN_SRC java
+// Camera position captured via Developer Tools -> Copy
+viewPanel.getCamera().getTransform().set(
+        130.66, -65.49, -248.18,   // x, y, z
+        -0.06, -0.36, -0.00);      // yaw, pitch, roll
+#+END_SRC
+
+** Frustum culling statistics
+:PROPERTIES:
+:CUSTOM_ID: frustum-culling-statistics
+:END:
+
+Shows real-time statistics about composite shape frustum culling
+efficiency (see the dedicated [[file:Frustum culling/][frustum culling]] page for how
+culling itself works):
+
+| Statistic | Description                                              |
+|-----------+----------------------------------------------------------|
+| Total     | Number of composite shapes tested against the frustum    |
+| Culled    | Number of composites rejected (outside view frustum)     |
+| Culled %  | Percentage of composites that were culled (0-100%)       |
+
+*How to interpret the numbers:*
+
+- *High cull % (60-90%)*: Excellent — most objects are being correctly culled
+- *Medium cull % (20-60%)*: Moderate — some optimization benefit
+- *Low cull % (0-20%)*: Limited benefit — either all objects are visible, or scene needs restructuring
+
+*Example:*
+#+BEGIN_EXAMPLE
+Total: 473  Culled: 425 (89.9%)
+#+END_EXAMPLE
+
+This means 473 composite shapes were tested, 425 were outside the view
+and skipped entirely, and only 48 composites (with all their children)
+actually needed to be rendered. This is excellent culling efficiency.
+
+The statistics update every 200ms while the panel is open. Note that
+the root composite is never frustum-tested (it's always rendered), so
+the "Total" count excludes it.
+
+** Render threads
+:PROPERTIES:
+:CUSTOM_ID: render-threads
+:END:
+
+Shows the number of active render threads versus available CPU cores.
+The engine defaults to 75% of available threads (at most cores − 1, so
+one thread always stays free for the rest of the system). The count is
+changeable at runtime via [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/ViewPanel.html#setNumRenderThreads(int)][ViewPanel.setNumRenderThreads(int)]];
+the worker pool is recreated lazily on the next frame.
+
+** Frame rate
+:PROPERTIES:
+:CUSTOM_ID: frame-rate
+:END:
+
+Shows the current target FPS and the measured production rate (frames
+completed per second, averaged over a ~500 ms window). The measured
+number counts produced frames regardless of how quickly the display
+path presents them — see [[file:Rendering loop/index.org::#frame-rate-control][frame rate control]].
+
+The *Unlock FPS* toggle switches to unlimited (benchmark) mode: the
+engine renders continuously as fast as possible, even when the scene
+is static. Toggling off restores the previously locked target rate.
+
+** Thread activity timeline
+:PROPERTIES:
+:CUSTOM_ID: thread-activity-timeline
+:END:
+
+A per-thread occupancy view — the software-renderer equivalent of a
+GPU frame profiler. Each thread gets a row (the render thread and
+present thread on top, then one row per worker), time runs along the X
+axis, and each colored block is one recorded work interval. Idle time
+is black.
+
+#+attr_html: :class responsive-img
+[[file:Developer tools/Thread timeline.png]]
+
+Press *Record* to start capturing. The colors encode both the task
+kind and which frame the task belongs to — transform, paint, and
+binning come in three frame-parity variants (f0/f1/f2), so you can see
+up to three frames in flight simultaneously. Additional colors mark
+render-thread orchestration, blocked time, blits, and the sort/drain
+sub-phases.
+
+Navigation: mouse wheel scrolls, Ctrl+wheel zooms, and a scrollbar
+moves along the captured range.
+
+The legend colors, exactly as the timeline paints them:
+
+| Color | Legend label | What it shows |
+|-------+--------------+---------------|
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#2ECC40;border:1px solid #444;vertical-align:middle"></span>@@ =#2ECC40= | =transform f0= | Vertex transform chunk task, frame slot 0 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#B8D900;border:1px solid #444;vertical-align:middle"></span>@@ =#B8D900= | =transform f1= | Vertex transform chunk task, frame slot 1 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#6B8E23;border:1px solid #444;vertical-align:middle"></span>@@ =#6B8E23= | =transform f2= | Vertex transform chunk task, frame slot 2 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#0074D9;border:1px solid #444;vertical-align:middle"></span>@@ =#0074D9= | =paint f0= | Paint tile task (clear + rasterize one tile), frame slot 0 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#F012BE;border:1px solid #444;vertical-align:middle"></span>@@ =#F012BE= | =paint f1= | Paint tile task, frame slot 1 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#B10DC9;border:1px solid #444;vertical-align:middle"></span>@@ =#B10DC9= | =paint f2= | Paint tile task, frame slot 2 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#39CCCC;border:1px solid #444;vertical-align:middle"></span>@@ =#39CCCC= | =bin f0= | Tile binning task (assign sorted shapes to tiles), frame slot 0 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#008B8B;border:1px solid #444;vertical-align:middle"></span>@@ =#008B8B= | =bin f1= | Tile binning task, frame slot 1 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#007070;border:1px solid #444;vertical-align:middle"></span>@@ =#007070= | =bin f2= | Tile binning task, frame slot 2 |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#A0A0A0;border:1px solid #444;vertical-align:middle"></span>@@ =#A0A0A0= | =render serial= | Render thread orchestration: tree walk and pass submission |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#8B0000;border:1px solid #444;vertical-align:middle"></span>@@ =#8B0000= | =blocked= | Render thread waiting for an older paint pass or the present gate |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#FFFFFF;border:1px solid #444;vertical-align:middle"></span>@@ =#FFFFFF= | =blit= | Present thread copying a finished frame to the screen |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#FF851B;border:1px solid #444;vertical-align:middle"></span>@@ =#FF851B= | =sort+bin= | A pass's async continuation as a whole: drain, sort, bin, submit paint |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#8B4513;border:1px solid #444;vertical-align:middle"></span>@@ =#8B4513= | =drain= | Continuation sub-phase: await and merge parallel transform chunks |
+| @@html:<span style="display:inline-block;width:2.2em;height:0.9em;background:#FFD700;border:1px solid #444;vertical-align:middle"></span>@@ =#FFD700= | =sort= | Continuation sub-phase: depth sort of the pass's shapes |
+
+The f0/f1/f2 suffixes are the frame's projection slot (frame number
+modulo 3) — the triple-buffering from the [[file:Rendering loop/index.org::#software-pipeline][software pipeline]].
+When the pipeline is healthy you see interleaved colors from two or
+three frames on the worker rows at once: paint tasks of an older frame
+overlapping transform and binning of the newer one. Wide =blocked=
+spans on the render row, or worker rows with black gaps, mean the
+pipeline is starved rather than busy.
+
+Recording is cheap but not free (~100 ns per task; a single volatile
+read when disabled), so leave it off during benchmarking runs. The
+captured intervals live in a fixed-size ring buffer — long recordings
+keep only the most recent history.
+
+What to look for:
+
+- *Solidly packed worker rows* mean the pipeline is keeping all cores
+  busy — the design goal (see [[file:Rendering loop/index.org::#software-pipeline][software pipeline]])
+- *Long "blocked" spans on the render row* mean the render thread is
+  waiting for paint passes — workers are the bottleneck
+- *Long "blit" spans on the present row* mean the display path
+  (X server) is the bottleneck; excess frames are being dropped from
+  the presentation mailbox
+
+** Live log viewer
+:PROPERTIES:
+:CUSTOM_ID: live-log-viewer
+:END:
+
+The scrollable text area shows captured debug output in real-time:
+- Green text on black background for readability
+- Auto-scrolls to show latest entries
+- Updates every 200ms while panel is open
+- Captures logs even when panel is closed (replays when reopened)
+
+Use the *Clear Logs* button to reset the log buffer for fresh
+diagnostic captures.
+
+** Headless & agentic tooling
+:PROPERTIES:
+:CUSTOM_ID: headless-agentic
+:END:
+
+For windowless rendering, pixel assertions, golden-image regression
+tests and scene dumps — built for automated verification and AI agents —
+see [[#agentic-development][agentic development tools]].
+
+* Agentic development
+:PROPERTIES:
+:CUSTOM_ID: agentic-development
+:END:
+
+*Aukio 3D* provides good support for automated AI coding agents
+(for example OpenCode, Hermes Agent, etc..).
+
+Thanks to facilities is =eu.svjatoslav.aukio.e3d.headless= package, an
+AI agent can render any scene from any pose, assert what got painted,
+compare against committed reference images, and dump the full scene
+state for a bug report — all without a window, a display, or the
+render thread.
+
+** One pipeline, two drivers
+:PROPERTIES:
+:CUSTOM_ID: one-pipeline
+:END:
+
+The key design decision: the headless path drives *the very same
+transform &rarr; sort &rarr; paint pipeline* the on-screen ViewPanel
+uses. Nothing is reimplemented, so a passing headless test proves the
+real render works, and a bug reproduced headlessly is the real bug.
+
+#+INCLUDE: "Agentic development/Headless lanes.svg" export html
+
+Making this possible required three small engine changes:
+
+- ~ShapeCollection.transformShapes(Camera, RenderingContext)~ — the
+  transform phase now accepts a camera directly; the ViewPanel variant
+  just forwards its camera. Headless code never touches Swing.
+- ~RenderingContext.getImage()~ — hands out the backing BufferedImage
+  the rasterizer paints into.
+- ~GlobalIllumination.isRunning()~ / ~isConverged()~ /
+  ~getWorkItemCount()~ — GI state became inspectable.
+
+** Snapshot: render without a window
+:PROPERTIES:
+:CUSTOM_ID: snapshot
+:END:
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.headless.Snapshot;
+
+ShapeCollection scene = new ShapeCollection();
+scene.addShape(myShape);
+LightingManager lighting = new LightingManager();
+lighting.setAmbientLight(Color.hex("181818"));
+
+// One call: build context, transform, sort, paint, return the image.
+BufferedImage image = Snapshot.render(scene, lighting,
+        "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480);
+Snapshot.save(image, "/tmp/snapshot.png");
+#+END_SRC
+
+The pose string is the *same "x, y, z, yaw, pitch, roll" format the
+demos print* and users quote in bug reports — paste the pose, reproduce
+the exact view. ~Snapshot.cameraFromPose()~ and ~Snapshot.poseString()~
+convert in both directions.
+
+For tests that need to detect *unpainted* pixels (holes), the
+~renderInto()~ variant fills the background with a caller-chosen
+sentinel color first, so "nothing was painted here" is unambiguous even
+in a pitch-black scene:
+
+#+BEGIN_SRC java
+RenderingContext ctx = new RenderingContext(640, 480, 1);
+ctx.lightingManager = lighting;
+Snapshot.renderInto(scene, camera, ctx, 0x00010203); // sentinel
+#+END_SRC
+
+A real headless render — the House demo from a bug-report pose:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:Agentic development/snapshot-example.png]]
+
+** PixelAssertions: did this region get painted?
+:PROPERTIES:
+:CUSTOM_ID: pixel-assertions
+:END:
+
+The recurring debugging question — "did the floor actually render, or
+did clipping eat it?" — becomes a library call:
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.headless.PixelAssertions;
+
+// Fraction of a relative rectangle still equal to the background:
+double holes = PixelAssertions.unpaintedFraction(image, 0,
+        0.15, 0.45, 0.85, 1.0);   // lower-center band
+if (holes > 0.05)
+    throw new AssertionError("floor has holes: " + holes);
+
+long red = PixelAssertions.countColor(image, 0xFF0000);   // flat-color tests
+String grid = PixelAssertions.dumpPixelGrid(image, 320, 240, 3, 8); // hex dump
+#+END_SRC
+
+#+INCLUDE: "Agentic development/Pixel assertion.svg" export html
+
+** GoldenImage: compare against a reference
+:PROPERTIES:
+:CUSTOM_ID: golden-image
+:END:
+
+A pixel counts as different when any RGB channel drifts more than a
+per-channel tolerance; the comparison fails when the fraction of
+differing pixels exceeds a threshold. Deterministic flat-shaded renders
+can use tight tolerances (4, 0.005); noisier paths relax them.
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.e3d.headless.GoldenImage;
+
+GoldenImage.Result r = GoldenImage.compare(actual,
+        new File("goldens/house-flat.png"), 4, 0.005);
+if (!r.passed)
+    GoldenImage.saveDiff(actual, goldenFile, "/tmp/diff.png"); // red = differs
+#+END_SRC
+
+#+INCLUDE: "Agentic development/Golden workflow.svg" export html
+
+There is also a CLI for shell scripts — exit 0 = match, 1 = differ:
+
+#+BEGIN_SRC bash
+java eu.svjatoslav.aukio.e3d.headless.GoldenImage actual.png golden.png 4 0.005
+#+END_SRC
+
+A real diff: the house rendered with the living-room lamp removed,
+compared against the golden. The red region is exactly the room that
+lost its light:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 640px
+[[file:Agentic development/diff-example.png]]
+
+** SceneDump: the reproducible bug report
+:PROPERTIES:
+:CUSTOM_ID: scene-dump
+:END:
+
+One call produces everything needed to reproduce what a frame shows:
+
+#+BEGIN_SRC java
+System.out.println(SceneDump.dump(scene, lighting, camera, gi));
+#+END_SRC
+
+#+BEGIN_EXAMPLE
+== SceneDump ==
+shapes: 5 top-level, 546 queued for rendering
+lights: 4 (ambient #181818)
+  [0] pos=(-800.0, -240.0, 0.0) color=FFD890 intensity=6.0
+  [1] pos=(0.0, -240.0, 0.0) color=D8E4FF intensity=5.0
+  [2] pos=(800.0, -240.0, 0.0) color=FFB060 intensity=6.0
+  [3] pos=(250.0, -60.0, -250.0) color=60FF90 intensity=2.0
+camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00
+GI: running, 152034 work items, converged
+#+END_EXAMPLE
+
+The camera line is a pose string — it feeds straight back into
+~Snapshot.render()~.
+
+** HouseGoldens: ready-made regression tests
+:PROPERTIES:
+:CUSTOM_ID: house-goldens
+:END:
+
+The aukio-3d-demos repo contains a working example of all of the above:
+~eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens~ renders the
+House demo at two poses (the default view and the near-plane straddle
+bug pose), compares both against committed goldens, and independently
+asserts the floor has no holes.
+
+#+BEGIN_SRC bash
+cd aukio-3d-demos
+mvn clean package
+mvn -q dependency:build-classpath -Dmdep.outputFile=cp.txt
+java -cp "target/classes:$(cat cp.txt)" \
+  eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens           # verify
+java -cp "target/classes:$(cat cp.txt)" \
+  eu.svjatoslav.aukio.e3d.examples.goldens.HouseGoldens --update  # regenerate goldens
+#+END_SRC
+
+Exit code 0 = all pass, 1 = any mismatch (with a diff PNG in /tmp).
+Demos that want the same treatment expose their scene construction:
+~HouseDemo.buildHouse()~, ~addFurniture()~ and ~addLights()~ are public
+for exactly this reason.
+
+** export-docs.sh: regenerate the documentation
+:PROPERTIES:
+:CUSTOM_ID: export-docs
+:END:
+
+All engine documentation (the pages you are reading) lives as org-mode
+files under =doc/=. One script exports every page to HTML with the
+darksun theme:
+
+#+BEGIN_SRC bash
+doc/export-docs.sh            # export all pages
+doc/export-docs.sh --check    # + render every page with headless Chrome
+                              #   to /tmp/doc-check-*.png for visual review
+#+END_SRC
+
+The =--check= mode is how an agent verifies its own documentation: SVG
+label collisions, broken image links and table breakage all show up in
+the rendered screenshots.
+
+** A typical agent session
+:PROPERTIES:
+:CUSTOM_ID: typical-session
+:END:
+
+#+BEGIN_EXAMPLE
+1. Reproduce:   Snapshot.render(scene, lighting, bugReportPose, 640, 480)
+2. Inspect:     SceneDump.dump(...) + view the PNG
+3. Fix the engine
+4. Verify:      PixelAssertions.unpaintedFraction(...) == 0
+5. Regression:  HouseGoldens  (must stay ALL PASS)
+6. Document:    edit doc pages, export-docs.sh --check, review shots
+#+END_EXAMPLE
+
+** Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class           | Purpose                                            |
+|-----------------+----------------------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/Snapshot.html][Snapshot]]         | Windowless render facade + pose string conversion  |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.html][PixelAssertions]]  | Painted-region / color-count / hex-grid assertions |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/GoldenImage.html][GoldenImage]]      | Golden-PNG comparison, diff writer, CLI            |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/headless/SceneDump.html][SceneDump]]        | Scene state as a reproducible text block           |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.html][ShapeCollection]]  | ~transformShapes(Camera, ...)~ headless overload   |
+| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/gui/RenderingContext.html][RenderingContext]] | ~getImage()~ exposes the painted frame             |
+
+* Source code
+:PROPERTIES:
+:CUSTOM_ID: source-code
+:ID:       978b7ea2-e246-45d0-be76-4d561308e9f3
+:END:
+
+*This program is free software: released under Creative Commons Zero
+(CC0) license*
+
+*Program author:*
+- Svjatoslav Agejenko
+- Homepage: https://svjatoslav.eu
+- Email: mailto://svjatoslav@svjatoslav.eu
+- See also: [[https://www.svjatoslav.eu/projects/][Other software projects hosted at svjatoslav.eu]]
+
+*Getting the source code:*
+- [[https://www2.svjatoslav.eu/gitweb/?p=aukio-3d.git;a=snapshot;h=HEAD;sf=tgz][Download latest source code snapshot in TAR GZ format]]
+- [[https://www2.svjatoslav.eu/gitweb/?p=aukio-3d.git;a=summary][Browse Git repository online]]
+- Clone Git repository using command:
+  : git clone https://www3.svjatoslav.eu/git/aukio-3d.git
diff --git a/doc/style.css b/doc/style.css
new file mode 100644 (file)
index 0000000..3403e0e
--- /dev/null
@@ -0,0 +1,35 @@
+.flex-center {
+  display: flex;
+  justify-content: center;
+}
+
+.flex-center video {
+  width: min(90%, 1000px);
+  height: auto;
+}
+
+.responsive-img {
+  width: min(100%, 1000px);
+  height: auto;
+}
+
+/* === SVG diagram theme === */
+svg > rect:first-child {
+  fill: #061018;
+}
+
+svg text[fill="#666"],
+svg text[fill="#999"] {
+  fill: #aaa !important;
+}
+
+svg line[stroke="#ccc"] {
+  stroke: #445566 !important;
+}
+
+svg {
+  background-color: #061018;
+  border-radius: 8px;
+  display: block;
+  margin: 0 auto;
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644 (file)
index 0000000..3cfe059
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,151 @@
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>eu.svjatoslav</groupId>
+    <artifactId>aukio-3d</artifactId>
+    <version>1.5-SNAPSHOT</version>
+    <name>Aukio 3D</name>
+    <description>3D engine</description>
+
+    <properties>
+        <java.version>21</java.version>
+        <maven.compiler.source>21</maven.compiler.source>
+        <maven.compiler.target>21</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+    </properties>
+
+    <organization>
+        <name>svjatoslav.eu</name>
+        <url>https://svjatoslav.eu</url>
+    </organization>
+
+    <dependencies>
+        <dependency>
+            <groupId>net.java.dev.jna</groupId>
+            <artifactId>jna</artifactId>
+            <version>5.14.0</version>
+        </dependency>
+
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.12</version>
+            <scope>test</scope>
+        </dependency>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <source>21</source>
+                    <target>21</target>
+                    <optimize>true</optimize>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-source-plugin</artifactId>
+                <version>2.2.1</version>
+                <executions>
+                    <execution>
+                        <id>attach-sources</id>
+                        <goals>
+                            <goal>jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-javadoc-plugin</artifactId>
+                <version>2.10.4</version>
+                <executions>
+                    <execution>
+                        <id>attach-javadocs</id>
+                        <goals>
+                            <goal>jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+                <configuration>
+                    <!-- workaround for https://bugs.openjdk.java.net/browse/JDK-8212233 -->
+                    <javaApiLinks>
+                        <property>
+                            <name>foo</name>
+                            <value>bar</value>
+                        </property>
+                    </javaApiLinks>
+                    <!-- Workaround for https://stackoverflow.com/questions/49472783/maven-is-unable-to-find-javadoc-command -->
+                    <javadocExecutable>${java.home}/bin/javadoc</javadocExecutable>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-resources-plugin</artifactId>
+                <version>2.4.3</version>
+                <configuration>
+                    <encoding>UTF-8</encoding>
+                </configuration>
+            </plugin>
+
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-release-plugin</artifactId>
+                <version>2.5.2</version>
+                <dependencies>
+                    <dependency>
+                        <groupId>org.apache.maven.scm</groupId>
+                        <artifactId>maven-scm-provider-gitexe</artifactId>
+                        <version>1.9.4</version>
+                    </dependency>
+                </dependencies>
+            </plugin>
+        </plugins>
+
+        <extensions>
+            <extension>
+                <groupId>org.apache.maven.wagon</groupId>
+                <artifactId>wagon-ssh-external</artifactId>
+                <version>2.6</version>
+            </extension>
+        </extensions>
+    </build>
+
+
+    <distributionManagement>
+        <snapshotRepository>
+            <id>svjatoslav.eu</id>
+            <name>svjatoslav.eu</name>
+            <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+        </snapshotRepository>
+        <repository>
+            <id>svjatoslav.eu</id>
+            <name>svjatoslav.eu</name>
+            <url>scpexe://svjatoslav.eu:10006/srv/maven</url>
+        </repository>
+    </distributionManagement>
+
+    <repositories>
+        <repository>
+            <id>svjatoslav.eu</id>
+            <name>Svjatoslav repository</name>
+            <url>https://www3.svjatoslav.eu/maven/</url>
+        </repository>
+    </repositories>
+
+    <scm>
+        <connection>scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio-3d.git</connection>
+        <developerConnection>scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/aukio-3d.git</developerConnection>
+        <tag>HEAD</tag>
+    </scm>
+
+</project>
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Diagnostics.java
new file mode 100644 (file)
index 0000000..28e86c5
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.diag;
+
+/**
+ * Facade that wires up Aukio diagnostics: persistent rolling log plus the
+ * periodic telemetry line.
+ *
+ * <p>Installed automatically when the first {@code ViewPanel} is created,
+ * or explicitly at application startup ({@code Diagnostics.install()} as
+ * the first statement of {@code main()}) so early boot output is captured
+ * too. Disable entirely with {@code -De3d.diagnostics=false}.</p>
+ */
+public final class Diagnostics {
+
+    private static volatile boolean installed = false;
+
+    private Diagnostics() {
+    }
+
+    /** Installs persistent logging and starts telemetry. Idempotent. */
+    public static synchronized void install() {
+        if (installed)
+            return;
+        installed = true;
+        if ("false".equalsIgnoreCase(
+                System.getProperty("e3d.diagnostics", "true"))) {
+            System.out.println("[DIAG] diagnostics disabled"
+                    + " (e3d.diagnostics=false)");
+            return;
+        }
+        PersistentLog.install();
+        Telemetry.start();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/EngineConfig.java
new file mode 100644 (file)
index 0000000..cd9a083
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.diag;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Properties;
+
+/**
+ * User configuration for Aukio, read from a properties file in the user's
+ * home config directory.
+ *
+ * <p>Location: {@code ~/.config/aukio/config.properties} (override with
+ * {@code -De3d.config=<path>}). A missing file means built-in defaults;
+ * unknown keys are ignored.</p>
+ *
+ * <p>Recognized keys:</p>
+ * <ul>
+ *   <li>{@code ipd.cm} — interpupillary distance in centimeters for
+ *       stereoscopic (3D glasses) rendering. Default 6.5.</li>
+ *   <li>{@code bugreport.dir} — directory under which bug reports are
+ *       written. Default {@code ~/.local/share/aukio/bugreports}.</li>
+ *   <li>{@code log.dir} — directory for persistent rolling logs.
+ *       Default {@code ~/.cache/aukio/logs}.</li>
+ *   <li>{@code telemetry.interval.seconds} — how often the telemetry
+ *       line is written to the log. Default 5.</li>
+ * </ul>
+ *
+ * <p>Every key can also be set as a system property
+ * ({@code -De3d.ipd=6.3}, {@code -De3d.bugreport.dir=...},
+ * {@code -De3d.log.dir=...}, {@code -De3d.telemetry.interval=...});
+ * system properties win over the config file.</p>
+ */
+public final class EngineConfig {
+
+    /** Default stereo IPD in world units (centimeters). */
+    public static final double DEFAULT_IPD_CM = 6.5;
+
+    private static final Properties PROPERTIES = new Properties();
+
+    static {
+        final File configFile = new File(System.getProperty("e3d.config",
+                System.getProperty("user.home")
+                        + "/.config/aukio/config.properties"));
+        if (configFile.isFile()) {
+            try (FileInputStream in = new FileInputStream(configFile)) {
+                PROPERTIES.load(in);
+            } catch (final IOException e) {
+                System.err.println("[CONFIG] could not read " + configFile
+                        + ": " + e.getMessage());
+            }
+        }
+    }
+
+    private EngineConfig() {
+    }
+
+    /**
+     * Interpupillary distance for stereo rendering, in world units
+     * (centimeters).
+     */
+    public static double getIpdCm() {
+        return parseDouble(System.getProperty("e3d.ipd",
+                PROPERTIES.getProperty("ipd.cm")), DEFAULT_IPD_CM);
+    }
+
+    /** Directory under which bug reports are written. */
+    public static File getBugReportDir() {
+        return new File(expandHome(System.getProperty("e3d.bugreport.dir",
+                PROPERTIES.getProperty("bugreport.dir",
+                        "~/.local/share/aukio/bugreports"))));
+    }
+
+    /** Directory for persistent rolling logs. */
+    public static File getLogDir() {
+        return new File(expandHome(System.getProperty("e3d.log.dir",
+                PROPERTIES.getProperty("log.dir",
+                        "~/.cache/aukio/logs"))));
+    }
+
+    /** Telemetry write interval, seconds. */
+    public static int getTelemetryIntervalSeconds() {
+        return (int) parseDouble(
+                System.getProperty("e3d.telemetry.interval",
+                        PROPERTIES.getProperty("telemetry.interval.seconds")),
+                5);
+    }
+
+    private static double parseDouble(final String value,
+                                      final double fallback) {
+        if (value == null || value.isBlank())
+            return fallback;
+        try {
+            return Double.parseDouble(value.trim());
+        } catch (final NumberFormatException e) {
+            return fallback;
+        }
+    }
+
+    private static String expandHome(final String path) {
+        if (path.startsWith("~/"))
+            return System.getProperty("user.home") + path.substring(1);
+        return path;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/PersistentLog.java
new file mode 100644 (file)
index 0000000..fa61506
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.diag;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+
+/**
+ * Persistent rolling log: tees {@code System.out} and {@code System.err}
+ * into a log file on disk so a hung or crashed session still leaves its
+ * full output behind.
+ *
+ * <p>The file lives at {@code <logDir>/aukio.log} and is rotated to
+ * {@code aukio.log.1} (one backup kept) once it exceeds
+ * {@value #MAX_LOG_BYTES}. Writes are flushed per line so the last output
+ * before an OutOfMemory freeze is on disk even when the application can
+ * no longer react to window events.</p>
+ *
+ * @see Diagnostics
+ */
+public final class PersistentLog {
+
+    /** Rotate the log once it exceeds this size. */
+    private static final long MAX_LOG_BYTES = 4L * 1024 * 1024;
+
+    private static volatile boolean installed = false;
+    private static volatile File logFile;
+
+    private PersistentLog() {
+    }
+
+    /**
+     * Installs the tee. Idempotent; failures (unwritable directory) fall
+     * back to console-only logging and are reported on stderr.
+     */
+    public static synchronized void install() {
+        if (installed)
+            return;
+        installed = true;
+
+        final File dir = EngineConfig.getLogDir();
+        try {
+            Files.createDirectories(dir.toPath());
+            logFile = new File(dir, "aukio.log");
+            final SharedFileOutput shared = new SharedFileOutput(logFile);
+            System.setOut(new PrintStream(
+                    new TeeStream(System.out, shared), true));
+            System.setErr(new PrintStream(
+                    new TeeStream(System.err, shared), true));
+            System.out.println("[LOG] persistent log: "
+                    + logFile.getAbsolutePath());
+        } catch (final IOException e) {
+            System.err.println("[LOG] persistent logging unavailable in "
+                    + dir + ": " + e.getMessage());
+            logFile = null;
+        }
+    }
+
+    /** The current log file, or null when persistent logging failed. */
+    public static File getLogFile() {
+        return logFile;
+    }
+
+    /**
+     * Single shared append stream to the log file; both the stdout and
+     * stderr tees write through it. Rotates the file once it grows past
+     * {@link #MAX_LOG_BYTES}.
+     */
+    private static final class SharedFileOutput {
+
+        private final File file;
+        private OutputStream out;
+
+        SharedFileOutput(final File file) throws IOException {
+            this.file = file;
+            this.out = new FileOutputStream(file, true);
+        }
+
+        synchronized void write(final int b) throws IOException {
+            rotateIfNeeded();
+            out.write(b);
+            if (b == '\n')
+                out.flush();
+        }
+
+        synchronized void write(final byte[] b, final int off,
+                                final int len) throws IOException {
+            rotateIfNeeded();
+            out.write(b, off, len);
+            out.flush();
+        }
+
+        private void rotateIfNeeded() throws IOException {
+            if (file.length() < MAX_LOG_BYTES)
+                return;
+            out.close();
+            final File backup = new File(file.getParentFile(),
+                    file.getName() + ".1");
+            Files.move(file.toPath(), backup.toPath(),
+                    StandardCopyOption.REPLACE_EXISTING);
+            out = new FileOutputStream(file, true);
+        }
+    }
+
+    /** Mirrors writes to the console and to the shared log file. */
+    private static final class TeeStream extends OutputStream {
+
+        private final PrintStream console;
+        private final SharedFileOutput file;
+
+        TeeStream(final PrintStream console, final SharedFileOutput file) {
+            this.console = console;
+            this.file = file;
+        }
+
+        @Override
+        public void write(final int b) throws IOException {
+            console.write(b);
+            file.write(b);
+        }
+
+        @Override
+        public void write(final byte[] b, final int off, final int len)
+                throws IOException {
+            console.write(b, off, len);
+            file.write(b, off, len);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java b/src/main/java/eu/svjatoslav/aukio/e3d/diag/Telemetry.java
new file mode 100644 (file)
index 0000000..6d9ca92
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.diag;
+
+import java.lang.management.ManagementFactory;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+/**
+ * Periodic telemetry line written to the persistent log: heap usage plus
+ * whatever counters registered subsystems report (streaming caches,
+ * loaded cells, queue depths, measured FPS...).
+ *
+ * <p>Slowdown-toward-OOM issues show up here as a monotonic climb in one
+ * of the numbers across a flight, which names the leaking resource. The
+ * line is written every {@code telemetry.interval.seconds} (default 5)
+ * and goes to stdout — the {@link PersistentLog} tee puts it on disk even
+ * when the application later freezes.</p>
+ */
+public final class Telemetry {
+
+    private static final DateTimeFormatter TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    private static final Map<String, Supplier<String>> SOURCES =
+            new ConcurrentHashMap<>();
+
+    private static volatile boolean started = false;
+
+    private Telemetry() {
+    }
+
+    /**
+     * Registers a named telemetry source. The supplier must be fast and
+     * side-effect free; it runs on the telemetry thread. Registering the
+     * same name again replaces the previous source.
+     *
+     * @param name   short subsystem name, e.g. {@code "fo4"}
+     * @param source produces a compact stats string, e.g.
+     *               {@code "cells=12 tris=450000"}
+     */
+    public static void registerSource(final String name,
+                                      final Supplier<String> source) {
+        SOURCES.put(name, source);
+    }
+
+    /** Removes a previously registered source (subsystem shutdown). */
+    public static void unregisterSource(final String name) {
+        SOURCES.remove(name);
+    }
+
+    /** Starts the daemon telemetry thread. Idempotent. */
+    public static synchronized void start() {
+        if (started)
+            return;
+        started = true;
+        final int intervalSeconds =
+                Math.max(1, EngineConfig.getTelemetryIntervalSeconds());
+        final Thread thread = new Thread(() -> {
+            while (true) {
+                try {
+                    Thread.sleep(intervalSeconds * 1000L);
+                } catch (final InterruptedException e) {
+                    return;
+                }
+                try {
+                    System.out.println(snapshotLine());
+                } catch (final Throwable t) {
+                    // telemetry must never take the application down
+                }
+            }
+        }, "aukio-telemetry");
+        thread.setDaemon(true);
+        thread.start();
+    }
+
+    /**
+     * Builds the current telemetry line. Also used by bug reports to
+     * include a final snapshot.
+     */
+    public static String snapshotLine() {
+        final Runtime rt = Runtime.getRuntime();
+        final long used = rt.totalMemory() - rt.freeMemory();
+        final StringBuilder sb = new StringBuilder("TELEMETRY ");
+        sb.append(LocalDateTime.now().format(TIME_FORMATTER));
+        sb.append(String.format(" heap=%dM/%dM threads=%d",
+                used / (1024 * 1024), rt.maxMemory() / (1024 * 1024),
+                ManagementFactory.getThreadMXBean().getThreadCount()));
+        for (final Map.Entry<String, Supplier<String>> entry
+                : SOURCES.entrySet()) {
+            try {
+                sb.append(' ').append(entry.getKey()).append("={")
+                        .append(entry.getValue().get()).append('}');
+            } catch (final Throwable t) {
+                sb.append(' ').append(entry.getKey()).append("={error}");
+            }
+        }
+        return sb.toString();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Box.java
new file mode 100644 (file)
index 0000000..7ae2362
--- /dev/null
@@ -0,0 +1,216 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import static java.lang.Math.abs;
+
+/**
+ * A 3D axis-aligned bounding box defined by two corner points.
+ *
+ * <p>Also known as: 3D rectangle, rectangular box, rectangular parallelepiped,
+ * cuboid, rhomboid, hexahedron, or rectangular prism.</p>
+ *
+ * <p>The box is defined by two points ({@link #p1} and {@link #p2}) that represent
+ * opposite corners. The box does not enforce ordering of these points.</p>
+ *
+ * <p><b>Example usage:</b></p>
+ * <pre>{@code
+ * Box box = new Box(new Point3D(0, 0, 0), new Point3D(100, 50, 200));
+ * double volume = box.getWidth() * box.getHeight() * box.getDepth();
+ * box.enlarge(10);  // expand by 10 units in all directions
+ * }</pre>
+ *
+ * @see Point3D
+ */
+public class Box implements Cloneable {
+
+    /**
+     * The first corner point of the box.
+     */
+    public final Point3D p1;
+    /**
+     * The second corner point of the box (opposite corner from p1).
+     */
+    public final Point3D p2;
+
+    /**
+     * Creates a new box with both corner points at the origin.
+     */
+    public Box() {
+        p1 = new Point3D();
+        p2 = new Point3D();
+    }
+
+    /**
+     * Creates a new box with the specified corner points.
+     *
+     * @param p1 the first corner point
+     * @param p2 the second corner point (opposite corner)
+     */
+    public Box(final Point3D p1, final Point3D p2) {
+        this.p1 = p1;
+        this.p2 = p2;
+    }
+
+
+    /**
+     * Enlarges the box by the specified border in all directions.
+     *
+     * @param border The border to enlarge the box by.
+     *               If the border is negative, the box will be shrunk.
+     * @return The current box.
+     */
+    public Box enlarge(final double border) {
+
+        if (p1.x < p2.x) {
+            p1.translateX(-border);
+            p2.translateX(border);
+        } else {
+            p1.translateX(border);
+            p2.translateX(-border);
+        }
+
+        if (p1.y < p2.y) {
+            p1.translateY(-border);
+            p2.translateY(border);
+        } else {
+            p1.translateY(border);
+            p2.translateY(-border);
+        }
+
+        if (p1.z < p2.z) {
+            p1.translateZ(-border);
+            p2.translateZ(border);
+        } else {
+            p1.translateZ(border);
+            p2.translateZ(-border);
+        }
+
+        return this;
+    }
+
+    /**
+     * Creates a copy of this box with cloned corner points.
+     *
+     * @return a new box with the same corner coordinates
+     */
+    @Override
+    public Box clone() {
+        return new Box(p1.clone(), p2.clone());
+    }
+
+    /**
+     * Returns the depth of the box (distance along the Z-axis).
+     *
+     * @return the depth (always positive)
+     */
+    public double getDepth() {
+        return abs(p1.z - p2.z);
+    }
+
+    /**
+     * Returns the height of the box (distance along the Y-axis).
+     *
+     * @return the height (always positive)
+     */
+    public double getHeight() {
+        return abs(p1.y - p2.y);
+    }
+
+    /**
+     * Returns the width of the box (distance along the X-axis).
+     *
+     * @return the width (always positive)
+     */
+    public double getWidth() {
+        return abs(p1.x - p2.x);
+    }
+
+
+    /**
+     * Sets the size of the box. The box will be centered at the origin.
+     * Previous size and position of the box will be lost.
+     *
+     * @param size {@link Point3D} specifies box size in x, y and z axis.
+     */
+    public void setBoxSize(final Point3D size) {
+        p2.clone(size).divide(2);
+        p1.clone(p2).negate();
+    }
+
+    /**
+     * Returns the minimum X coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the smaller X value of p1 and p2
+     */
+    public double getMinX() {
+        return Math.min(p1.x, p2.x);
+    }
+
+    /**
+     * Returns the maximum X coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the larger X value of p1 and p2
+     */
+    public double getMaxX() {
+        return Math.max(p1.x, p2.x);
+    }
+
+    /**
+     * Returns the minimum Y coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the smaller Y value of p1 and p2
+     */
+    public double getMinY() {
+        return Math.min(p1.y, p2.y);
+    }
+
+    /**
+     * Returns the maximum Y coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the larger Y value of p1 and p2
+     */
+    public double getMaxY() {
+        return Math.max(p1.y, p2.y);
+    }
+
+    /**
+     * Returns the minimum Z coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the smaller Z value of p1 and p2
+     */
+    public double getMinZ() {
+        return Math.min(p1.z, p2.z);
+    }
+
+    /**
+     * Returns the maximum Z coordinate of this box.
+     * Useful for AABB intersection tests.
+     *
+     * @return the larger Z value of p1 and p2
+     */
+    public double getMaxZ() {
+        return Math.max(p1.z, p2.z);
+    }
+
+    /**
+     * Returns the geometric center of this box.
+     *
+     * @return a new Point3D at the center of the box
+     */
+    public Point3D getCenter() {
+        return new Point3D(
+                (p1.x + p2.x) / 2.0,
+                (p1.y + p2.y) / 2.0,
+                (p1.z + p2.z) / 2.0
+        );
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/BspTree.java
new file mode 100644 (file)
index 0000000..f6542b2
--- /dev/null
@@ -0,0 +1,230 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A Binary Space Partitioning (BSP) tree for CSG operations.
+ *
+ * <p>BSP trees are the data structure that makes CSG boolean operations possible.
+ * Each node divides 3D space into two half-spaces using a plane, enabling
+ * efficient spatial queries and polygon clipping.</p>
+ *
+ * <p><b>BSP Tree Structure:</b></p>
+ * <pre>
+ *                 [Node: plane P]
+ *                /               \
+ *        [Front subtree]     [Back subtree]
+ *     (same side as P's     (opposite side
+ *        normal)             of P's normal)
+ * </pre>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ * @see Plane the plane type used for spatial partitioning
+ * @see SolidPolygon the polygon type stored in BSP nodes
+ */
+public class BspTree {
+
+    /**
+     * Polygons that lie on this node's partitioning plane.
+     */
+    public final List<SolidPolygon> polygons = new ArrayList<>();
+
+    /**
+     * The partitioning plane for this node.
+     */
+    public Plane plane;
+
+    /**
+     * The front child subtree.
+     */
+    public BspTree front;
+
+    /**
+     * The back child subtree.
+     */
+    public BspTree back;
+
+    /**
+     * Creates an empty BSP tree with no plane or children.
+     */
+    public BspTree() {
+    }
+
+    /**
+     * Creates a BSP tree from a list of polygons.
+     *
+     * @param polygons the polygons to partition into a BSP tree
+     */
+    public BspTree(final List<SolidPolygon> polygons) {
+        addPolygons(polygons);
+    }
+
+    /**
+     * Creates a deep clone of this BSP tree.
+     *
+     * @return a new BspTree with cloned data
+     */
+    public BspTree clone() {
+        final BspTree tree = new BspTree();
+
+        tree.plane = plane != null ? plane.clone() : null;
+        tree.front = front != null ? front.clone() : null;
+        tree.back = back != null ? back.clone() : null;
+
+        for (final SolidPolygon p : polygons) {
+            tree.polygons.add(p.deepClone());
+        }
+
+        return tree;
+    }
+
+    /**
+     * Inverts this BSP tree, converting "inside" to "outside" and vice versa.
+     */
+    public void invert() {
+        for (final SolidPolygon polygon : polygons) polygon.flip();
+
+        if (plane != null) plane.flip();
+        if (front != null) front.invert();
+        if (back != null) back.invert();
+
+        final BspTree temp = front;
+        front = back;
+        back = temp;
+    }
+
+    /**
+     * Clips a list of polygons against this BSP tree, returning only the
+     * portions that lie outside the solid represented by this tree.
+     *
+     * <p>This is a core CSG operation used for boolean subtraction and
+     * intersection. The method recursively traverses the BSP tree, splitting
+     * polygons at each partitioning plane and discarding interior fragments.</p>
+     *
+     * <p><b>Algorithm:</b></p>
+     * <ol>
+     *   <li>At each node, split polygons by the partitioning plane</li>
+     *   <li>Recursively clip front fragments against the front subtree</li>
+     *   <li>Recursively clip back fragments against the back subtree</li>
+     *   <li>Combine and return all surviving fragments</li>
+     * </ol>
+     *
+     * <p><b>Leaf nodes:</b> If this node has no plane (leaf node), all polygons
+     * are considered outside and returned unchanged.</p>
+     *
+     * @param polygons the polygons to clip against this BSP tree
+     * @return a new list containing only the portions outside this solid
+     */
+    public List<SolidPolygon> clipPolygons(final List<SolidPolygon> polygons) {
+        // Leaf node: no partitioning plane means all polygons are outside
+        if (plane == null) {
+            return new ArrayList<>(polygons);
+        }
+
+        // Split polygons by this node's partitioning plane
+        final List<SolidPolygon> frontList = new ArrayList<>();
+        final List<SolidPolygon> backList = new ArrayList<>();
+
+        for (final SolidPolygon polygon : polygons)
+            // Split by plane: coplanar polygons are classified by their normal direction
+            // (same-facing normal → frontList, opposite-facing normal → backList)
+            plane.splitPolygon(polygon, frontList, backList, frontList, backList);
+
+        // Recursively clip front fragments against front subtree
+        List<SolidPolygon> resultFront = frontList;
+        if (front != null) resultFront = front.clipPolygons(frontList);
+
+        // Recursively clip back fragments against back subtree
+        List<SolidPolygon> resultBack;
+        if (back != null) resultBack = back.clipPolygons(backList);
+        else resultBack = new ArrayList<>();
+
+        // Combine surviving fragments from both subtrees
+        final List<SolidPolygon> result = new ArrayList<>(resultFront.size() + resultBack.size());
+        result.addAll(resultFront);
+        result.addAll(resultBack);
+        return result;
+    }
+
+    /**
+     * Clips this BSP tree against another BSP tree.
+     *
+     * @param bsp the BSP tree to clip against
+     */
+    public void clipTo(final BspTree bsp) {
+        final List<SolidPolygon> newPolygons = bsp.clipPolygons(polygons);
+        polygons.clear();
+        polygons.addAll(newPolygons);
+
+        if (front != null) front.clipTo(bsp);
+        if (back != null) back.clipTo(bsp);
+    }
+
+    /**
+     * Collects all polygons from this BSP tree into a flat list.
+     *
+     * @return a new list containing all polygons in this tree
+     */
+    public List<SolidPolygon> allPolygons() {
+        final List<SolidPolygon> result = new ArrayList<>(polygons);
+
+        if (front != null) result.addAll(front.allPolygons());
+        if (back != null) result.addAll(back.allPolygons());
+
+        return result;
+    }
+
+    /**
+     * Adds polygons to this BSP tree, partitioning space recursively.
+     *
+     * <p>This method is the core BSP tree construction algorithm. It builds or
+     * extends the tree by choosing a partition plane and classifying each polygon:</p>
+     *
+     * <ul>
+     *   <li><b>Coplanar</b> — polygons on the partition plane are stored in this node</li>
+     *   <li><b>Front</b> — polygons in the front half-space (same side as plane normal)
+     *       go to the front child subtree</li>
+     *   <li><b>Back</b> — polygons in the back half-space (opposite to plane normal)
+     *       go to the back child subtree</li>
+     *   <li><b>Spanning</b> — polygons crossing the plane are split into front and back
+     *       fragments, each going to its respective subtree</li>
+     * </ul>
+     *
+     * <p>For an empty tree, the first polygon's plane becomes the partition plane.
+     * Child nodes are created lazily when polygons need to be stored in them.</p>
+     *
+     * <p>Can be called multiple times to incrementally extend an existing tree,
+     * though the original partition planes remain unchanged.</p>
+     *
+     * @param polygons the polygons to insert into this BSP tree
+     * @see Plane#splitPolygon the method that classifies and splits individual polygons
+     */
+    public void addPolygons(final List<SolidPolygon> polygons) {
+        if (polygons.isEmpty()) return;
+
+        if (plane == null) plane = polygons.get(0).getPlane().clone();
+
+        final List<SolidPolygon> frontList = new ArrayList<>();
+        final List<SolidPolygon> backList = new ArrayList<>();
+
+        for (final SolidPolygon polygon : polygons)
+            plane.splitPolygon(polygon, this.polygons, this.polygons, frontList, backList);
+
+        if (!frontList.isEmpty()) {
+            if (front == null) front = new BspTree();
+            front.addPolygons(frontList);
+        }
+
+        if (!backList.isEmpty()) {
+            if (back == null) back = new BspTree();
+            back.addPolygons(backList);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Circle.java
new file mode 100644 (file)
index 0000000..70eca94
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+/**
+ * A circle in 2D space defined by a center point and radius.
+ *
+ * @see Point2D
+ */
+public class Circle {
+
+    /**
+     * The center point of the circle.
+     */
+    Point2D location;
+
+    /**
+     * The radius of the circle.
+     */
+    double radius;
+
+    /**
+     * Creates a circle with default values.
+     */
+    public Circle() {
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Frustum.java
new file mode 100644 (file)
index 0000000..d1075c6
--- /dev/null
@@ -0,0 +1,266 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+
+/**
+ * View frustum for frustum culling - eliminates objects outside the camera's view.
+ *
+ * <p>The frustum is a truncated pyramid-shaped volume that represents everything
+ * the camera can see. Objects completely outside this volume can be skipped
+ * during rendering, significantly improving performance for large scenes.</p>
+ *
+ * <p><b>Frustum planes:</b></p>
+ * <ul>
+ *   <li>Left, Right, Top, Bottom - define the viewport edges</li>
+ *   <li>Near - closest visible distance from camera</li>
+ *   <li>Far - farthest visible distance from camera</li>
+ * </ul>
+ *
+ * <p><b>Usage:</b></p>
+ * <pre>{@code
+ * Frustum frustum = new Frustum();
+ * frustum.update(camera, screenWidth, screenHeight);
+ *
+ * Box objectBounds = shape.getBoundingBox();
+ * if (frustum.intersectsAABB(objectBounds)) {
+ *     // Object is potentially visible - render it
+ * } else {
+ *     // Object outside frustum - skip rendering
+ * }
+ * }</pre>
+ *
+ * <p><b>AABB intersection algorithm:</b></p>
+ * <p>Uses the optimized "P-vertex" approach: for each plane, we test only
+ * the AABB corner most aligned with the plane normal. If this corner is
+ * behind the plane, the entire AABB is outside the frustum.</p>
+ *
+ * @see Box axis-aligned bounding box for culling tests
+ * @see Camera provides position and orientation for frustum computation
+ */
+public class Frustum {
+
+    /**
+     * Index for the left clipping plane.
+     */
+    public static final int LEFT = 0;
+    /**
+     * Index for the right clipping plane.
+     */
+    public static final int RIGHT = 1;
+    /**
+     * Index for the top clipping plane.
+     */
+    public static final int TOP = 2;
+    /**
+     * Index for the bottom clipping plane.
+     */
+    public static final int BOTTOM = 3;
+    /**
+     * Index for the near clipping plane.
+     */
+    public static final int NEAR = 4;
+    /**
+     * Index for the far clipping plane.
+     */
+    public static final int FAR = 5;
+
+    /**
+     * The six clipping planes defining the frustum volume.
+     * Each plane is stored as (normal, distance) in Hesse normal form.
+     * Planes are in world space coordinates.
+     */
+    private final Plane[] planes = new Plane[6];
+
+    /**
+     * Default near plane distance from camera (in world units).
+     * Objects closer than this are culled.
+     */
+    private double nearDistance = 1.0;
+
+    /**
+     * Default far plane distance from camera (in world units).
+     * Objects farther than this are culled.
+     */
+    private double farDistance = 10000.0;
+
+    /**
+     * Creates a new frustum with uninitialized planes.
+     * Call {@link #update} before using for culling.
+     */
+    public Frustum() {
+        for (int i = 0; i < 6; i++) {
+            planes[i] = new Plane(new Point3D(0, 0, 1), 0);
+        }
+    }
+
+    /**
+     * Updates the frustum planes in view space (camera at origin, looking along +Z).
+     *
+     * <p>This method should be called once per frame before rendering, after the
+     * camera position and orientation have been updated.</p>
+     *
+     * <p><b>View space coordinate system:</b></p>
+     * <ul>
+     *   <li>Camera at origin (0, 0, 0)</li>
+     *   <li>Forward = +Z axis (looking into the screen)</li>
+     *   <li>Right = +X axis</li>
+     *   <li>Up = -Y axis (since Y-down means smaller Y is higher visually)</li>
+     * </ul>
+     *
+     * <p><b>Plane normals point INTO the frustum</b> (toward the visible volume).
+     * A point is inside if dot(normal, point) >= distance for all planes.</p>
+     *
+     * <p><b>FOV calculation:</b> The Aukio 3D engine uses projectionScale = width/3.
+     * This means tan(halfHFOV) = (width/2) / projectionScale = 1.5, giving a
+     * horizontal FOV of approximately 112 degrees.</p>
+     *
+     * @param camera the camera (used only for aspect ratio derivation from width/height)
+     * @param width  the viewport width in pixels (defines projectionScale)
+     * @param height the viewport height in pixels (used for vertical FOV)
+     */
+    public void update(final Camera camera, final int width, final int height) {
+        // Frustum is computed in VIEW SPACE (camera at origin, looking along +Z)
+        // This matches the coordinate system after applying camera transforms
+
+        // Aukio 3D uses projectionScale = width/3
+        // tan(halfFOV) = (halfSize) / projectionScale
+        final double projectionScale = width / 3.0;
+        final double tanHalfHFOV = (width / 2.0) / projectionScale;  // = 1.5 (very wide FOV)
+        final double tanHalfVFOV = (height / 2.0) / projectionScale; // depends on aspect ratio
+
+        // Compute cosine and sine of half-FOV angles
+        // cosHalfFOV = 1 / sqrt(1 + tanHalfFOV^2)
+        // sinHalfFOV = tanHalfFOV * cosHalfFOV
+        final double cosHalfHFOV = 1.0 / Math.sqrt(1.0 + tanHalfHFOV * tanHalfHFOV);
+        final double sinHalfHFOV = tanHalfHFOV * cosHalfHFOV;
+        final double cosHalfVFOV = 1.0 / Math.sqrt(1.0 + tanHalfVFOV * tanHalfVFOV);
+        final double sinHalfVFOV = tanHalfVFOV * cosHalfVFOV;
+
+        // Near and far distances
+        nearDistance = 1.0;
+        farDistance = 10000.0;
+
+        // All side planes pass through origin (camera position in view space)
+        // Plane equation: dot(normal, point) >= distance means inside
+
+        // Left plane: inward normal pointing right-forward
+        // Bounds: x >= -tanHalfHFOV * z (to the right of left edge)
+        planes[LEFT].normal = new Point3D(cosHalfHFOV, 0, sinHalfHFOV);
+        planes[LEFT].distance = 0;
+
+        // Right plane: inward normal pointing left-forward
+        // Bounds: x <= tanHalfHFOV * z (to the left of right edge)
+        planes[RIGHT].normal = new Point3D(-cosHalfHFOV, 0, sinHalfHFOV);
+        planes[RIGHT].distance = 0;
+
+        // Top plane: inward normal pointing down-forward (Y-down system, top is smaller Y)
+        // Bounds: y <= tanHalfVFOV * z (below top edge, smaller Y)
+        planes[TOP].normal = new Point3D(0, -cosHalfVFOV, sinHalfVFOV);
+        planes[TOP].distance = 0;
+
+        // Bottom plane: inward normal pointing up-forward (larger Y is below)
+        // Bounds: y >= -tanHalfVFOV * z (above bottom edge, larger Y)
+        planes[BOTTOM].normal = new Point3D(0, cosHalfVFOV, sinHalfVFOV);
+        planes[BOTTOM].distance = 0;
+
+        // Near plane: inward normal pointing forward (+Z)
+        // Bounds: z >= nearDistance (in front of near plane)
+        planes[NEAR].normal = new Point3D(0, 0, 1);
+        planes[NEAR].distance = nearDistance;
+
+        // Far plane: inward normal pointing backward (-Z)
+        // Bounds: z <= farDistance (behind far plane)
+        planes[FAR].normal = new Point3D(0, 0, -1);
+        planes[FAR].distance = -farDistance;
+    }
+
+    /**
+     * Tests whether an axis-aligned bounding box intersects the frustum.
+     *
+     * <p>This is a conservative test: returns {@code true} if the box is
+     * potentially visible (inside or partially inside the frustum), and
+     * {@code false} only if the box is completely outside all frustum planes.</p>
+     *
+     * <p><b>Optimized algorithm:</b></p>
+     * <p>For each plane, we test only the AABB corner most aligned with the
+     * plane normal (the "P-vertex"). If this corner is behind the plane,
+     * the entire AABB must be outside the frustum.</p>
+     *
+     * @param box the axis-aligned bounding box to test (in view space coordinates)
+     * @return {@code true} if the box intersects or is inside the frustum,
+     *         {@code false} if completely outside
+     */
+    public boolean intersectsAABB(final Box box) {
+        // Get box min/max for each axis
+        final double minX = box.getMinX();
+        final double maxX = box.getMaxX();
+        final double minY = box.getMinY();
+        final double maxY = box.getMaxY();
+        final double minZ = box.getMinZ();
+        final double maxZ = box.getMaxZ();
+
+        for (int i = 0; i < 6; i++) {
+            final Plane plane = planes[i];
+            final Point3D n = plane.normal;
+            final double d = plane.distance;
+
+            // Find the P-vertex: the corner most aligned with the plane normal
+            // If normal component is positive, use max; if negative, use min
+            final double px = (n.x > 0) ? maxX : minX;
+            final double py = (n.y > 0) ? maxY : minY;
+            final double pz = (n.z > 0) ? maxZ : minZ;
+
+            // Test if P-vertex is outside the frustum (behind the plane)
+            // For inward-pointing normals: inside = dot(N,P) >= distance
+            // So outside = dot(N,P) < distance
+            if (n.x * px + n.y * py + n.z * pz < d) {
+                return false; // AABB entirely outside this plane
+            }
+        }
+
+        return true; // AABB intersects or inside all planes
+    }
+
+    /**
+     * Returns the near clipping plane distance.
+     *
+     * @return the near distance in world units
+     */
+    public double getNearDistance() {
+        return nearDistance;
+    }
+
+    /**
+     * Returns the far clipping plane distance.
+     *
+     * @return the far distance in world units
+     */
+    public double getFarDistance() {
+        return farDistance;
+    }
+
+    /**
+     * Sets the near and far clipping distances.
+     *
+     * @param near the near plane distance (objects closer are culled)
+     * @param far  the far plane distance (objects farther are culled)
+     */
+    public void setClipDistances(final double near, final double far) {
+        this.nearDistance = near;
+        this.farDistance = far;
+    }
+
+    /**
+     * Returns a specific frustum plane for debugging or advanced usage.
+     *
+     * @param planeIndex one of LEFT, RIGHT, TOP, BOTTOM, NEAR, FAR
+     * @return the plane at the specified index
+     */
+    public Plane getPlane(final int planeIndex) {
+        return planes[planeIndex];
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Plane.java
new file mode 100644 (file)
index 0000000..1d5c289
--- /dev/null
@@ -0,0 +1,227 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents an infinite plane in 3D space using the Hesse normal form.
+ *
+ * <p>Planes are fundamental to BSP (Binary Space Partitioning) tree operations
+ * in CSG. They divide 3D space into two half-spaces.</p>
+ *
+ * @see SolidPolygon polygons that reference their containing plane
+ * @see BspTree BSP trees that use planes for spatial partitioning
+ */
+public class Plane {
+
+    /**
+     * Epsilon value used for floating-point comparisons in BSP operations.
+     * Smaller values provide higher precision but may cause issues with
+     * near-coplanar polygons. 1e-5 is a good balance for most 3D geometry.
+     */
+    public static final double EPSILON = 1e-12;
+
+    /**
+     * The unit normal vector perpendicular to the plane surface.
+     */
+    public Point3D normal;
+
+    /**
+     * The signed distance from the origin to the plane along the normal.
+     */
+    public double distance;
+
+    /**
+     * Creates a plane with the given normal and distance.
+     *
+     * @param normal   the unit normal vector
+     * @param distance the signed distance from origin to the plane
+     */
+    public Plane(final Point3D normal, final double distance) {
+        this.normal = normal;
+        this.distance = distance;
+    }
+
+    /**
+     * Computes the unit normal vector for a triangle defined by three points.
+     *
+     * <p>Zero-allocation method: fills the result point instead of creating a new one.
+     * This is the shared implementation used by both {@link #fromPoints} and
+     * {@link SolidPolygon} for shading calculations.</p>
+     *
+     * <p>The normal is computed as the cross product of two edge vectors (b-a and c-a),
+     * then normalized to unit length.</p>
+     *
+     * @param a     first point (base point for edge vectors)
+     * @param b     second point
+     * @param c     third point
+     * @param result Point3D to receive the unit normal vector (modified in place)
+     * @return true if normal computed successfully, false if points are collinear
+     *         (cross product magnitude less than EPSILON)
+     */
+    public static boolean computeNormal(final Point3D a, final Point3D b,
+                                        final Point3D c, final Point3D result) {
+        // Edge vectors from a to b and a to c
+        final double ax = b.x - a.x;
+        final double ay = b.y - a.y;
+        final double az = b.z - a.z;
+
+        final double bx = c.x - a.x;
+        final double by = c.y - a.y;
+        final double bz = c.z - a.z;
+
+        // Cross product: (edge1 × edge2)
+        double nx = ay * bz - az * by;
+        double ny = az * bx - ax * bz;
+        double nz = ax * by - ay * bx;
+
+        // Normalize
+        final double length = Math.sqrt(nx * nx + ny * ny + nz * nz);
+        if (length < EPSILON) {
+            result.x = result.y = result.z = 0;
+            return false;
+        }
+
+        result.x = nx / length;
+        result.y = ny / length;
+        result.z = nz / length;
+        return true;
+    }
+
+    /**
+     * Creates a plane from three non-collinear points.
+     *
+     * <p>Uses {@link #computeNormal} for the normal calculation, then computes
+     * the signed distance from origin using the dot product.</p>
+     *
+     * @param a the first point on the plane
+     * @param b the second point on the plane
+     * @param c the third point on the plane
+     * @return a new Plane passing through the three points
+     * @throws ArithmeticException if the points are collinear (cannot define a plane)
+     */
+    public static Plane fromPoints(final Point3D a, final Point3D b, final Point3D c) {
+        final Point3D n = new Point3D();
+        if (!computeNormal(a, b, c, n)) {
+            throw new ArithmeticException(
+                    "Cannot create plane from collinear points: cross product is zero");
+        }
+        return new Plane(n, n.dot(a));
+    }
+
+    /**
+     * Creates a deep clone of this plane.
+     *
+     * @return a new Plane with the same normal and distance
+     */
+    public Plane clone() {
+        return new Plane(new Point3D(normal.x, normal.y, normal.z), distance);
+    }
+
+    /**
+     * Flips the plane orientation by negating the normal and distance.
+     */
+    public void flip() {
+        normal = normal.withNegated();
+        distance = -distance;
+    }
+
+    /**
+     * Splits a polygon by this plane, classifying and potentially dividing it.
+     *
+     * @param polygon       the polygon to classify and potentially split
+     * @param coplanarFront list to receive coplanar polygons with same-facing normals
+     * @param coplanarBack  list to receive coplanar polygons with opposite-facing normals
+     * @param front         list to receive polygons in the front half-space
+     * @param back          list to receive polygons in the back half-space
+     */
+    public void splitPolygon(final SolidPolygon polygon,
+                             final List<SolidPolygon> coplanarFront,
+                             final List<SolidPolygon> coplanarBack,
+                             final List<SolidPolygon> front,
+                             final List<SolidPolygon> back) {
+
+        PolygonType polygonType = PolygonType.COPLANAR;
+        final int vertexCount = polygon.getVertexCount();
+        final PolygonType[] types = new PolygonType[vertexCount];
+
+        for (int i = 0; i < vertexCount; i++) {
+            final Vertex v = polygon.vertices.get(i);
+            final double t = normal.dot(v.coordinate) - distance;
+            final PolygonType type = (t < -EPSILON) ? PolygonType.BACK
+                    : (t > EPSILON) ? PolygonType.FRONT : PolygonType.COPLANAR;
+            polygonType = polygonType.combine(type);
+            types[i] = type;
+        }
+
+        switch (polygonType) {
+            case COPLANAR:
+                ((normal.dot(polygon.getPlane().normal) > 0) ? coplanarFront : coplanarBack).add(polygon);
+                break;
+
+            case FRONT:
+                front.add(polygon);
+                break;
+
+            case BACK:
+                back.add(polygon);
+                break;
+
+            case SPANNING:
+                // Split spanning polygon by clipping each edge against the plane.
+                // Vertices on each side go to their respective lists.
+                // Edges crossing the plane create intersection vertices added to both lists.
+                final List<Vertex> frontVertices = new ArrayList<>();
+                final List<Vertex> backVertices = new ArrayList<>();
+
+                for (int i = 0; i < vertexCount; i++) {
+                    final int nextIndex = (i + 1) % vertexCount;
+                    final PolygonType currentType = types[i];
+                    final PolygonType nextType = types[nextIndex];
+                    final Vertex currentVertex = polygon.vertices.get(i);
+                    final Vertex nextVertex = polygon.vertices.get(nextIndex);
+
+                    // Add current vertex to the polygon on its side of the plane
+                    if (currentType.isFront()) {
+                        frontVertices.add(currentVertex.clone());
+                    }
+                    if (currentType.isBack()) {
+                        backVertices.add(currentVertex.clone());
+                    }
+
+                    // If edge crosses the plane, create intersection vertex for both polygons
+                    if (currentType != nextType
+                            && currentType != PolygonType.COPLANAR
+                            && nextType != PolygonType.COPLANAR) {
+                        // Calculate interpolation parameter t (0 = current, 1 = next)
+                        // t represents where along the edge the plane intersection occurs
+                        final double t = (distance - normal.dot(currentVertex.coordinate))
+                                / normal.dot(nextVertex.coordinate.withSubtracted(currentVertex.coordinate));
+
+                        final Vertex intersectionVertex = currentVertex.interpolate(nextVertex, t);
+                        frontVertices.add(intersectionVertex);
+                        backVertices.add(intersectionVertex.clone());
+                    }
+                }
+
+                if (frontVertices.size() >= 3) {
+                    final SolidPolygon frontPoly = SolidPolygon.fromVertices(
+                            frontVertices, polygon.getColor(), polygon.isShadingEnabled());
+                    front.add(frontPoly);
+                }
+                if (backVertices.size() >= 3) {
+                    final SolidPolygon backPoly = SolidPolygon.fromVertices(
+                            backVertices, polygon.getColor(), polygon.isShadingEnabled());
+                    back.add(backPoly);
+                }
+                break;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point2D.java
new file mode 100755 (executable)
index 0000000..7dc2004
--- /dev/null
@@ -0,0 +1,313 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import static java.lang.Math.sqrt;
+
+/**
+ * A mutable 2D point or vector with double-precision coordinates.
+ *
+ * <p>{@code Point2D} represents either a position in 2D space or a directional vector,
+ * with public {@code x} and {@code y} fields for direct access. It is commonly used
+ * for screen-space coordinates after 3D-to-2D projection.</p>
+ *
+ * <p>All mutation methods return {@code this} for fluent chaining:</p>
+ * <pre>{@code
+ * Point2D p = new Point2D(10, 20)
+ *     .multiply(2.0)
+ *     .add(new Point2D(5, 5))
+ *     .negate();
+ * // p is now (-25, -45)
+ * }</pre>
+ *
+ * <p><b>Mutability convention:</b></p>
+ * <ul>
+ *   <li><b>Imperative verbs</b> ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, 
+ *       {@code divide}) mutate this point and return {@code this}</li>
+ *   <li><b>{@code with}-prefixed methods</b> ({@code withAdded}, {@code withSubtracted}, {@code withNegated},
+ *       {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one</li>
+ * </ul>
+ *
+ * <p><b>Warning:</b> This class is mutable with public fields. Clone before storing
+ * references that should not be shared:</p>
+ * <pre>{@code
+ * Point2D safeCopy = original.clone();
+ * }</pre>
+ *
+ * @see Point3D the 3D equivalent
+ */
+public class Point2D implements Cloneable {
+
+    /** X coordinate (horizontal axis). */
+    public double x;
+    /** Y coordinate (vertical axis, positive = down in screen space). */
+    public double y;
+
+    /**
+     * Creates a point at the origin (0, 0).
+     */
+    public Point2D() {
+    }
+
+    /**
+     * Creates a point with the specified coordinates.
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     */
+    public Point2D(final double x, final double y) {
+        this.x = x;
+        this.y = y;
+    }
+
+    /**
+     * Creates a point by copying coordinates from another point.
+     *
+     * @param parent the point to copy from
+     */
+    public Point2D(final Point2D parent) {
+        x = parent.x;
+        y = parent.y;
+    }
+
+
+    /**
+     * Adds another point to this point in place.
+     * This point is modified, the other point is not.
+     *
+     * @param otherPoint the point to add
+     * @return this point (for chaining)
+     * @see #withAdded(Point2D) for the non-mutating version that returns a new point
+     */
+    public Point2D add(final Point2D otherPoint) {
+        x += otherPoint.x;
+        y += otherPoint.y;
+        return this;
+    }
+
+    /**
+     * Checks if both coordinates are zero.
+     *
+     * @return {@code true} if current point coordinates are equal to zero
+     */
+    public boolean isZero() {
+        return (x == 0) && (y == 0);
+    }
+
+    /**
+     * Creates a new point by copying this point's coordinates.
+     *
+     * @return a new point with the same coordinates
+     */
+    @Override
+    public Point2D clone() {
+        return new Point2D(this);
+    }
+
+    /**
+     * Copies coordinates from another point into this point.
+     *
+     * @param otherPoint the point to copy coordinates from
+     */
+    public void clone(final Point2D otherPoint) {
+        x = otherPoint.x;
+        y = otherPoint.y;
+    }
+
+    /**
+     * Sets this point to the midpoint between two other points.
+     *
+     * @param p1 the first point
+     * @param p2 the second point
+     * @return this point (for chaining)
+     */
+    public Point2D setToMiddle(final Point2D p1, final Point2D p2) {
+        x = (p1.x + p2.x) / 2d;
+        y = (p1.y + p2.y) / 2d;
+        return this;
+    }
+
+    /**
+     * Computes the angle on the X-Y plane between this point and another point.
+     *
+     * @param anotherPoint the other point
+     * @return the angle in radians
+     */
+    public double getAngleXY(final Point2D anotherPoint) {
+        return Math.atan2(x - anotherPoint.x, y - anotherPoint.y);
+    }
+
+    /**
+     * Computes the Euclidean distance from this point to another point.
+     *
+     * @param anotherPoint the point to compute distance to
+     * @return the distance between the two points
+     */
+    public double getDistanceTo(final Point2D anotherPoint) {
+        final double xDiff = x - anotherPoint.x;
+        final double yDiff = y - anotherPoint.y;
+
+        return sqrt(((xDiff * xDiff) + (yDiff * yDiff)));
+    }
+
+    /**
+     * Computes the length of this vector (magnitude).
+     *
+     * @return the vector length
+     */
+    public double getVectorLength() {
+        return sqrt(((x * x) + (y * y)));
+    }
+
+    /**
+     * Negates this point's coordinates in place.
+     * This point is modified.
+     *
+     * @return this point (for chaining)
+     * @see #withNegated() for the non-mutating version that returns a new point
+     */
+    public Point2D negate() {
+        x = -x;
+        y = -y;
+        return this;
+    }
+
+    /**
+     * Rounds this point's coordinates to integer values.
+     */
+    public void roundToInteger() {
+        x = (int) x;
+        y = (int) y;
+    }
+
+    /**
+     * Subtracts another point from this point in place.
+     * This point is modified, the other point is not.
+     *
+     * @param otherPoint the point to subtract
+     * @return this point (for chaining)
+     * @see #withSubtracted(Point2D) for the non-mutating version that returns a new point
+     */
+    public Point2D subtract(final Point2D otherPoint) {
+        x -= otherPoint.x;
+        y -= otherPoint.y;
+        return this;
+    }
+
+    /**
+     * Multiplies both coordinates by a factor.
+     * This point is modified.
+     *
+     * @param factor the multiplier
+     * @return this point (for chaining)
+     * @see #withMultiplied(double) for the non-mutating version that returns a new point
+     */
+    public Point2D multiply(final double factor) {
+        x *= factor;
+        y *= factor;
+        return this;
+    }
+
+    /**
+     * Divides both coordinates by a factor.
+     * This point is modified.
+     *
+     * @param factor the divisor
+     * @return this point (for chaining)
+     * @see #withDivided(double) for the non-mutating version that returns a new point
+     */
+    public Point2D divide(final double factor) {
+        x /= factor;
+        y /= factor;
+        return this;
+    }
+
+    /**
+     * Converts this 2D point to a 3D point with z = 0.
+     *
+     * @return a new 3D point with the same x, y and z = 0
+     */
+    public Point3D to3D() {
+        return new Point3D(x, y, 0);
+    }
+
+    /**
+     * Resets this point's coordinates to (0, 0).
+     *
+     * @return this point (for chaining)
+     */
+    public Point2D zero() {
+        x = 0;
+        y = 0;
+        return this;
+    }
+
+    @Override
+    public String toString() {
+        return "Point2D{" +
+                "x=" + x +
+                ", y=" + y +
+                '}';
+    }
+
+    /**
+     * Returns a new point that is the sum of this point and another.
+     * This point is not modified.
+     *
+     * @param other the point to add
+     * @return a new Point2D representing the sum
+     * @see #add(Point2D) for the mutating version
+     */
+    public Point2D withAdded(final Point2D other) {
+        return new Point2D(x + other.x, y + other.y);
+    }
+
+    /**
+     * Returns a new point that is this point minus another.
+     * This point is not modified.
+     *
+     * @param other the point to subtract
+     * @return a new Point2D representing the difference
+     * @see #subtract(Point2D) for the mutating version
+     */
+    public Point2D withSubtracted(final Point2D other) {
+        return new Point2D(x - other.x, y - other.y);
+    }
+
+    /**
+     * Returns a new point with negated coordinates.
+     * This point is not modified.
+     *
+     * @return a new Point2D with negated coordinates
+     * @see #negate() for the mutating version
+     */
+    public Point2D withNegated() {
+        return new Point2D(-x, -y);
+    }
+
+    /**
+     * Returns a new point with coordinates multiplied by a factor.
+     * This point is not modified.
+     *
+     * @param factor the multiplier
+     * @return a new Point2D with multiplied coordinates
+     * @see #multiply(double) for the mutating version
+     */
+    public Point2D withMultiplied(final double factor) {
+        return new Point2D(x * factor, y * factor);
+    }
+
+    /**
+     * Returns a new point with coordinates divided by a factor.
+     * This point is not modified.
+     *
+     * @param factor the divisor
+     * @return a new Point2D with divided coordinates
+     * @see #divide(double) for the mutating version
+     */
+    public Point2D withDivided(final double factor) {
+        return new Point2D(x / factor, y / factor);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Point3D.java
new file mode 100755 (executable)
index 0000000..91f7aa9
--- /dev/null
@@ -0,0 +1,586 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import eu.svjatoslav.aukio.e3d.renderer.octree.IntegerPoint;
+
+import static java.lang.Math.*;
+
+/**
+ * A mutable 3D point or vector with double-precision coordinates.
+ *
+ * <p>{@code Point3D} is the fundamental coordinate type used throughout the Aukio 3D engine.
+ * It represents either a position in 3D space or a directional vector, with public
+ * {@code x}, {@code y}, {@code z} fields for direct access.</p>
+ *
+ * <p>All mutation methods return {@code this} for fluent chaining:</p>
+ * <pre>{@code
+ * Point3D p = new Point3D(10, 20, 30)
+ *     .multiply(2.0)
+ *     .translateX(5)
+ *     .add(new Point3D(1, 1, 1));
+ * // p is now (25, 41, 61)
+ * }</pre>
+ *
+ * <p><b>Common operations:</b></p>
+ * <pre>{@code
+ * // Create points
+ * Point3D origin = Point3D.origin();          // (0, 0, 0)
+ * Point3D pos = Point3D.point(100, 200, 300);
+ * Point3D copy = new Point3D(pos);            // clone
+ *
+ * // Measure distance
+ * double dist = pos.getDistanceTo(origin);
+ *
+ * // Rotation
+ * pos.rotate(origin, Math.PI / 4, 0);  // rotate 45 degrees on XZ plane
+ *
+ * // Scale
+ * pos.multiply(2.0);   // double all coordinates
+ * pos.divide(2.0);     // halve all coordinates
+ * }</pre>
+ *
+ * <p><b>Mutability convention:</b></p>
+ * <ul>
+ *   <li><b>Imperative verbs</b> ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, 
+ *       {@code divide}) mutate this point and return {@code this}</li>
+ *   <li><b>{@code with}-prefixed methods</b> ({@code withAdded}, {@code withSubtracted}, {@code withNegated},
+ *       {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one</li>
+ * </ul>
+ *
+ * <p><b>Warning:</b> This class is mutable with public fields. Clone before storing
+ * references that should not be shared:</p>
+ * <pre>{@code
+ * Point3D safeCopy = original.clone();
+ * }</pre>
+ *
+ * @see Point2D the 2D equivalent
+ * @see eu.svjatoslav.aukio.e3d.math.Vertex wraps a Point3D with transform support
+ */
+public class Point3D implements Cloneable {
+
+    /** X coordinate (horizontal axis). */
+    public double x;
+    /** Y coordinate (vertical axis, positive = down in screen space). */
+    public double y;
+    /** Z coordinate (depth axis, positive = into the screen / away from viewer). */
+    public double z;
+
+    /**
+     * Creates a point at the origin (0, 0, 0).
+     */
+    public Point3D() {
+    }
+
+    /**
+     * Creates a point with the specified double-precision coordinates.
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     * @param z the Z coordinate
+     */
+    public Point3D(final double x, final double y, final double z) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Creates a point with the specified float coordinates (widened to double).
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     * @param z the Z coordinate
+     */
+    public Point3D(final float x, final float y, final float z) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Creates a point with the specified integer coordinates (widened to double).
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     * @param z the Z coordinate
+     */
+    public Point3D(final int x, final int y, final int z) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Creates a point from an {@link IntegerPoint} (used by octree voxel coordinates).
+     *
+     * @param point the integer point to convert
+     */
+    public Point3D(IntegerPoint point) {
+        this.x = point.x;
+        this.y = point.y;
+        this.z = point.z;
+    }
+
+
+    /**
+     * Creates a new point by cloning coordinates from the parent point.
+     *
+     * @param parent the point to copy coordinates from
+     */
+    public Point3D(final Point3D parent) {
+        x = parent.x;
+        y = parent.y;
+        z = parent.z;
+    }
+
+    /**
+     * Returns a new point at the origin (0, 0, 0).
+     *
+     * @return a new Point3D at the origin
+     */
+    public static Point3D origin() {
+        return new Point3D();
+    }
+
+    /**
+     * Returns a new point with the specified coordinates.
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     * @param z the Z coordinate
+     * @return a new Point3D with the given coordinates
+     */
+    public static Point3D point(final double x, final double y, final double z) {
+        return new Point3D(x, y, z);
+    }
+
+    /**
+     * Adds another point to this point in place.
+     * This point is modified, the other point is not.
+     *
+     * @param otherPoint the point to add
+     * @return this point (for chaining)
+     * @see #withAdded(Point3D) for the non-mutating version that returns a new point
+     */
+    public Point3D add(final Point3D otherPoint) {
+        x += otherPoint.x;
+        y += otherPoint.y;
+        z += otherPoint.z;
+        return this;
+    }
+
+    /**
+     * Adds coordinates of current point to one or more other points.
+     * The current point's coordinates are added to each target point.
+     *
+     * @param otherPoints the points to add this point's coordinates to
+     * @return this point (for chaining)
+     */
+    public Point3D addTo(final Point3D... otherPoints) {
+        for (final Point3D otherPoint : otherPoints) otherPoint.add(this);
+        return this;
+    }
+
+    /**
+     * Create new point by cloning position of current point.
+     *
+     * @return newly created clone.
+     */
+    public Point3D clone() {
+        return new Point3D(this);
+    }
+
+    /**
+     * Copies coordinates from another point into this point.
+     *
+     * @param otherPoint the point to copy coordinates from
+     * @return this point (for chaining)
+     */
+    public Point3D clone(final Point3D otherPoint) {
+        x = otherPoint.x;
+        y = otherPoint.y;
+        z = otherPoint.z;
+        return this;
+    }
+
+    /**
+     * Set current point coordinates to the middle point between two other points.
+     *
+     * @param p1 first point.
+     * @param p2 second point.
+     * @return current point.
+     */
+    public Point3D computeMiddlePoint(final Point3D p1, final Point3D p2) {
+        x = (p1.x + p2.x) / 2d;
+        y = (p1.y + p2.y) / 2d;
+        z = (p1.z + p2.z) / 2d;
+        return this;
+    }
+
+    /**
+     * Checks if all coordinates are zero.
+     *
+     * @return {@code true} if current point coordinates are equal to zero
+     */
+    public boolean isZero() {
+        return (x == 0) && (y == 0) && (z == 0);
+    }
+
+    /**
+     * Computes the angle on the X-Z plane between this point and another point.
+     *
+     * @param anotherPoint the other point
+     * @return the angle in radians
+     */
+    public double getAngleXZ(final Point3D anotherPoint) {
+        return Math.atan2(x - anotherPoint.x, z - anotherPoint.z);
+    }
+
+    /**
+     * Computes the angle on the Y-Z plane between this point and another point.
+     *
+     * @param anotherPoint the other point
+     * @return the angle in radians
+     */
+    public double getAngleYZ(final Point3D anotherPoint) {
+        return Math.atan2(y - anotherPoint.y, z - anotherPoint.z);
+    }
+
+    /**
+     * Computes the angle on the X-Y plane between this point and another point.
+     *
+     * @param anotherPoint the other point
+     * @return the angle in radians
+     */
+    public double getAngleXY(final Point3D anotherPoint) {
+        return Math.atan2(x - anotherPoint.x, y - anotherPoint.y);
+    }
+
+    /**
+     * Compute distance to another point.
+     *
+     * @param anotherPoint point to compute distance to.
+     * @return distance to another point.
+     */
+    public double getDistanceTo(final Point3D anotherPoint) {
+        final double xDelta = x - anotherPoint.x;
+        final double yDelta = y - anotherPoint.y;
+        final double zDelta = z - anotherPoint.z;
+
+        return sqrt(((xDelta * xDelta) + (yDelta * yDelta) + (zDelta * zDelta)));
+    }
+
+    /**
+     * Computes the length (magnitude) of this vector.
+     *
+     * @return the vector length
+     */
+    public double getVectorLength() {
+        return sqrt(((x * x) + (y * y) + (z * z)));
+    }
+
+    /**
+     * Negates this point's coordinates in place.
+     * This point is modified.
+     *
+     * @return this point (for chaining)
+     * @see #withNegated() for the non-mutating version that returns a new point
+     */
+    public Point3D negate() {
+        x = -x;
+        y = -y;
+        z = -z;
+        return this;
+    }
+
+    /**
+     * Rotates this point around a center point by the given XZ and YZ angles.
+     * <p>
+     * See also: <a href="https://marctenbosch.com/quaternions/">Let's remove Quaternions from every 3D Engine</a>
+     *
+     * @param center  the center point to rotate around
+     * @param angleXZ the angle in the XZ plane (yaw) in radians
+     * @param angleYZ the angle in the YZ plane (pitch) in radians
+     * @return this point (for chaining)
+     */
+    public Point3D rotate(final Point3D center, final double angleXZ,
+                          final double angleYZ) {
+        final double s1 = sin(angleXZ);
+        final double c1 = cos(angleXZ);
+
+        final double s2 = sin(angleYZ);
+        final double c2 = cos(angleYZ);
+
+        x -= center.x;
+        y -= center.y;
+        z -= center.z;
+
+        final double y1 = (z * s2) + (y * c2);
+        final double z1 = (z * c2) - (y * s2);
+
+        final double x1 = (z1 * s1) + (x * c1);
+        final double z2 = (z1 * c1) - (x * s1);
+
+        x = x1 + center.x;
+        y = y1 + center.y;
+        z = z2 + center.z;
+
+        return this;
+    }
+
+    /**
+     * Rotate current point around the origin by the given angles.
+     *
+     * @param angleXZ angle around the XZ plane (yaw), in radians
+     * @param angleYZ angle around the YZ plane (pitch), in radians
+     * @return this point (mutated)
+     */
+    public Point3D rotate(final double angleXZ, final double angleYZ) {
+        return rotate(new Point3D(0, 0, 0), angleXZ, angleYZ);
+    }
+
+    /**
+     * Round current point coordinates to integer values.
+     */
+    public void roundToInteger() {
+        x = (int) x;
+        y = (int) y;
+        z = (int) z;
+    }
+
+    /**
+     * Divides all coordinates by a factor.
+     * This point is modified.
+     *
+     * @param factor the divisor
+     * @return this point (for chaining)
+     * @see #withDivided(double) for the non-mutating version that returns a new point
+     */
+    public Point3D divide(final double factor) {
+        x /= factor;
+        y /= factor;
+        z /= factor;
+        return this;
+    }
+
+    /**
+     * Multiplies all coordinates by a factor.
+     * This point is modified.
+     *
+     * @param factor the multiplier
+     * @return this point (for chaining)
+     * @see #withMultiplied(double) for the non-mutating version that returns a new point
+     */
+    public Point3D multiply(final double factor) {
+        x *= factor;
+        y *= factor;
+        z *= factor;
+        return this;
+    }
+
+    /**
+     * Set current point coordinates to given values.
+     *
+     * @param x X coordinate.
+     * @param y Y coordinate.
+     * @param z Z coordinate.
+     */
+    public void setValues(final double x, final double y, final double z) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Subtracts another point from this point in place.
+     * This point is modified, the other point is not.
+     *
+     * @param otherPoint the point to subtract
+     * @return this point (for chaining)
+     * @see #withSubtracted(Point3D) for the non-mutating version that returns a new point
+     */
+    public Point3D subtract(final Point3D otherPoint) {
+        x -= otherPoint.x;
+        y -= otherPoint.y;
+        z -= otherPoint.z;
+        return this;
+    }
+
+    @Override
+    public String toString() {
+        return "x:" + x + " y:" + y + " z:" + z;
+    }
+
+    /**
+     * Translates this point along the X axis.
+     *
+     * @param xIncrement the amount to add to the X coordinate
+     * @return this point (for chaining)
+     */
+    public Point3D translateX(final double xIncrement) {
+        x += xIncrement;
+        return this;
+    }
+
+    /**
+     * Translates this point along the Y axis.
+     *
+     * @param yIncrement the amount to add to the Y coordinate
+     * @return this point (for chaining)
+     */
+    public Point3D translateY(final double yIncrement) {
+        y += yIncrement;
+        return this;
+    }
+
+    /**
+     * Translates this point along the Z axis.
+     *
+     * @param zIncrement the amount to add to the Z coordinate
+     * @return this point (for chaining)
+     */
+    public Point3D translateZ(final double zIncrement) {
+        z += zIncrement;
+        return this;
+    }
+
+    /**
+     * Here we assume that Z coordinate is distance to the viewer.
+     * If Z is positive, then point is in front of the viewer, and therefore it is visible.
+     *
+     * @return point visibility status.
+     */
+    public boolean isVisible() {
+        return z > 0;
+    }
+
+    /**
+     * Resets point coordinates to zero along all axes.
+     *
+     * @return current point.
+     */
+    public Point3D zero() {
+        x = 0;
+        y = 0;
+        z = 0;
+        return this;
+    }
+
+    /**
+     * Computes the dot product of this vector with another.
+     *
+     * @param other the other vector
+     * @return the dot product (scalar)
+     */
+    public double dot(final Point3D other) {
+        return x * other.x + y * other.y + z * other.z;
+    }
+
+    /**
+     * Computes the cross-product of this vector with another.
+     * Returns a new vector perpendicular to both input vectors.
+     *
+     * @param other the other vector
+     * @return a new Point3D representing the cross-product
+     */
+    public Point3D cross(final Point3D other) {
+        return new Point3D(
+                y * other.z - z * other.y,
+                z * other.x - x * other.z,
+                x * other.y - y * other.x
+        );
+    }
+
+    /**
+     * Returns a new point that is the sum of this point and another.
+     * This point is not modified.
+     *
+     * @param other the point to add
+     * @return a new Point3D representing the sum
+     * @see #add(Point3D) for the mutating version
+     */
+    public Point3D withAdded(final Point3D other) {
+        return new Point3D(x + other.x, y + other.y, z + other.z);
+    }
+
+    /**
+     * Returns a new point that is this point minus another.
+     * This point is not modified.
+     *
+     * @param other the point to subtract
+     * @return a new Point3D representing the difference
+     * @see #subtract(Point3D) for the mutating version
+     */
+    public Point3D withSubtracted(final Point3D other) {
+        return new Point3D(x - other.x, y - other.y, z - other.z);
+    }
+
+    /**
+     * Returns a new point with negated coordinates.
+     * This point is not modified.
+     *
+     * @return a new Point3D with negated coordinates
+     * @see #negate() for the mutating version
+     */
+    public Point3D withNegated() {
+        return new Point3D(-x, -y, -z);
+    }
+
+    /**
+     * Returns a new unit vector (normalized) in the same direction.
+     * This point is not modified.
+     *
+     * @return a new Point3D with unit length
+     */
+    public Point3D unit() {
+        final double len = getVectorLength();
+        if (len == 0) {
+            return new Point3D(0, 0, 0);
+        }
+        return new Point3D(x / len, y / len, z / len);
+    }
+
+    /**
+     * Returns a new point that is a linear interpolation between this point and another.
+     * When t=0, returns this point. When t=1, returns the other point.
+     *
+     * @param other the other point
+     * @param t     the interpolation parameter (0 to 1)
+     * @return a new Point3D representing the interpolated position
+     */
+    public Point3D interpolate(final Point3D other, final double t) {
+        return new Point3D(
+                x + (other.x - x) * t,
+                y + (other.y - y) * t,
+                z + (other.z - z) * t
+        );
+    }
+
+    /**
+     * Returns a new point with coordinates multiplied by a factor.
+     * This point is not modified.
+     *
+     * @param factor the multiplier
+     * @return a new Point3D with multiplied coordinates
+     * @see #multiply(double) for the mutating version
+     */
+    public Point3D withMultiplied(final double factor) {
+        return new Point3D(x * factor, y * factor, z * factor);
+    }
+
+    /**
+     * Returns a new point with coordinates divided by a factor.
+     * This point is not modified.
+     *
+     * @param factor the divisor
+     * @return a new Point3D with divided coordinates
+     * @see #divide(double) for the mutating version
+     */
+    public Point3D withDivided(final double factor) {
+        return new Point3D(x / factor, y / factor, z / factor);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Polygon.java
new file mode 100644 (file)
index 0000000..6c1b994
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+/**
+ * Utility class for polygon operations, primarily point-in-polygon testing.
+ *
+ * <p>Provides static methods for geometric computations on triangles and other polygons.</p>
+ *
+ * @see Point2D
+ */
+public class Polygon {
+
+    /**
+     * Creates a new Polygon utility instance.
+     */
+    public Polygon() {
+    }
+
+
+    /**
+     * Checks if a point is on the right side of a directed line segment.
+     * Used internally for ray-casting in point-in-polygon tests.
+     *
+     * @param point  the point to test
+     * @param lineP1 the start point of the line segment
+     * @param lineP2 the end point of the line segment
+     * @return {@code true} if the point is on the right side of the line
+     */
+    private static boolean intersectsLine(final Point2D point, Point2D lineP1,
+                                          Point2D lineP2) {
+
+        // Sort line points by y coordinate.
+        if (lineP1.y > lineP2.y) {
+            final Point2D tmp = lineP1;
+            lineP1 = lineP2;
+            lineP2 = tmp;
+        }
+
+        // Check if point is within line y range.
+        if (point.y < lineP1.y || point.y > lineP2.y)
+            return false;
+
+        // Check if point is on the line.
+        final double xp = lineP2.x - lineP1.x;
+        final double yp = lineP2.y - lineP1.y;
+
+        final double crossX = lineP1.x + ((xp * (point.y - lineP1.y)) / yp);
+
+        return point.x >= crossX;
+    }
+
+    /**
+     * Tests whether a point lies inside a triangle using the ray-casting algorithm.
+     *
+     * <p>Casts a horizontal ray from the test point and counts intersections
+     * with the triangle edges. If the number of intersections is odd, the point is inside.</p>
+     *
+     * @param point the point to test
+     * @param p1    the first vertex of the triangle
+     * @param p2    the second vertex of the triangle
+     * @param p3    the third vertex of the triangle
+     * @return {@code true} if the point is inside the triangle
+     */
+    public static boolean pointWithinPolygon(final Point2D point, Point2D p1, Point2D p2, Point2D p3) {
+
+        int intersectionCount = 0;
+
+        if (intersectsLine(point, p1, p2))
+            intersectionCount++;
+
+        if (intersectsLine(point, p2, p3))
+            intersectionCount++;
+
+        if (intersectsLine(point, p3, p1))
+            intersectionCount++;
+
+        return intersectionCount == 1;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/PolygonType.java
new file mode 100644 (file)
index 0000000..effc9d4
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+/**
+ * Classification of a polygon's position relative to a plane.
+ * Used in BSP tree operations to determine how polygons should be split.
+ */
+public enum PolygonType {
+    /** Polygon lies on the plane. */
+    COPLANAR,
+    /** Polygon is entirely in front of the plane. */
+    FRONT,
+    /** Polygon is entirely behind the plane. */
+    BACK,
+    /** Polygon straddles the plane (vertices on both sides). */
+    SPANNING;
+
+    /**
+     * Combines this type with another to compute the aggregate classification.
+     * When vertices are on both sides of a plane, the result is SPANNING.
+     *
+     * @param other the other polygon type to combine with
+     * @return the combined classification
+     */
+    public PolygonType combine(final PolygonType other) {
+        if (this == other || other == COPLANAR) {
+            return this;
+        }
+        if (this == COPLANAR) {
+            return other;
+        }
+        // FRONT + BACK = SPANNING
+        return SPANNING;
+    }
+
+    /**
+     * Checks if this type represents a vertex in front of the plane.
+     *
+     * @return true if FRONT or COPLANAR (treated as front for classification)
+     */
+    public boolean isFront() {
+        return this == FRONT || this == COPLANAR;
+    }
+
+    /**
+     * Checks if this type represents a vertex behind the plane.
+     *
+     * @return true if BACK or COPLANAR (treated as back for classification)
+     */
+    public boolean isBack() {
+        return this == BACK || this == COPLANAR;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/Rectangle.java
new file mode 100644 (file)
index 0000000..41b4195
--- /dev/null
@@ -0,0 +1,83 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
+
+import static java.lang.Math.abs;
+import static java.lang.Math.min;
+
+/**
+ * A 2D axis-aligned rectangle defined by two corner points.
+ *
+ * <p>The rectangle is defined by two points ({@link #p1} and {@link #p2}) that represent
+ * opposite corners. The rectangle does not enforce ordering of these points.</p>
+ *
+ * @see Point2D
+ * @see Box the 3D equivalent
+ */
+public class Rectangle {
+
+    /**
+     * The corner points of the rectangle (opposite corners).
+     */
+    public Point2D p1, p2;
+
+    /**
+     * Creates a square rectangle centered at the origin with the specified size.
+     *
+     * @param size the width and height of the square
+     */
+    public Rectangle(final double size) {
+        p2 = new Point2D(size / 2, size / 2);
+        p1 = p2.clone().negate();
+    }
+
+    /**
+     * Creates a rectangle with the specified corner points.
+     *
+     * @param p1 the first corner point
+     * @param p2 the second corner point (opposite corner)
+     */
+    public Rectangle(final Point2D p1, final Point2D p2) {
+        this.p1 = p1;
+        this.p2 = p2;
+    }
+
+    /**
+     * Returns the height of the rectangle (distance along the Y-axis).
+     *
+     * @return the height (always positive)
+     */
+    public double getHeight() {
+        return abs(p1.y - p2.y);
+    }
+
+    /**
+     * Returns the leftmost X coordinate of the rectangle.
+     *
+     * @return the minimum X value
+     */
+    public double getLowerX() {
+        return min(p1.x, p2.x);
+    }
+
+    /**
+     * Returns the topmost Y coordinate of the rectangle.
+     *
+     * @return the minimum Y value
+     */
+    public double getLowerY() {
+        return min(p1.y, p2.y);
+    }
+
+    /**
+     * Returns the width of the rectangle (distance along the X-axis).
+     *
+     * @return the width (always positive)
+     */
+    public double getWidth() {
+        return abs(p1.x - p2.x);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/geometry/package-info.java
new file mode 100644 (file)
index 0000000..a4db80e
--- /dev/null
@@ -0,0 +1,7 @@
+/**
+ * Provides basic geometry classes for 2D and 3D coordinates and shapes.
+ *
+ * @see eu.svjatoslav.aukio.e3d.geometry.Point2D
+ * @see eu.svjatoslav.aukio.e3d.geometry.Point3D
+ */
+package eu.svjatoslav.aukio.e3d.geometry;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/BugReport.java
new file mode 100644 (file)
index 0000000..e684fe4
--- /dev/null
@@ -0,0 +1,232 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.diag.EngineConfig;
+import eu.svjatoslav.aukio.e3d.diag.PersistentLog;
+import eu.svjatoslav.aukio.e3d.diag.Telemetry;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+import javax.imageio.ImageIO;
+import java.awt.GraphicsDevice;
+import java.awt.GraphicsEnvironment;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+
+/**
+ * Writes a self-contained bug report directory the user can point a
+ * developer (or an AI assistant) at.
+ *
+ * <p>A new directory {@code bugreport-<timestamp>} is created under the
+ * configured {@code bugreport.dir} containing:</p>
+ * <ul>
+ *   <li>{@code description.txt} — what the user wrote in the popup</li>
+ *   <li>{@code info.txt} — camera pose, view/screen resolution, heap,
+ *       telemetry snapshot, java/os versions, effective config</li>
+ *   <li>{@code screenshot.png} — a copy of the last rendered frame</li>
+ *   <li>{@code aukio.log} / {@code aukio.log.1} — the persistent logs,
+ *       including output from a session that froze before the report
+ *       could be made</li>
+ *   <li>{@code histogram.txt} — live-object histogram from
+ *       {@code jcmd <pid> GC.class_histogram} (the OOM smoking gun;
+ *       skipped when jcmd is unavailable)</li>
+ *   <li>{@code threads.txt} — full thread dump (hang diagnosis)</li>
+ * </ul>
+ */
+public final class BugReport {
+
+    private static final DateTimeFormatter DIR_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
+
+    private BugReport() {
+    }
+
+    /**
+     * Creates a bug report for the given view.
+     *
+     * @param viewPanel   the view whose camera and last frame to capture
+     *                    (may be null — screenshot/camera are then
+     *                    skipped)
+     * @param description free-form user description of the problem
+     * @return the created report directory
+     */
+    public static File create(final ViewPanel viewPanel,
+                              final String description) throws IOException {
+        final File root = EngineConfig.getBugReportDir();
+        File dir = new File(root,
+                "bugreport-" + LocalDateTime.now().format(DIR_FORMATTER));
+        int suffix = 1;
+        while (dir.exists()) {
+            dir = new File(dir.getPath() + "-" + (++suffix));
+        }
+        Files.createDirectories(dir.toPath());
+
+        Files.writeString(new File(dir, "description.txt").toPath(),
+                description == null ? "" : description);
+        writeInfo(new File(dir, "info.txt"), viewPanel);
+        writeScreenshot(dir, viewPanel);
+        copyLogs(dir);
+        writeHistogram(dir);
+        writeThreadDump(new File(dir, "threads.txt"));
+
+        System.out.println("[BUGREPORT] written to "
+                + dir.getAbsolutePath());
+        return dir;
+    }
+
+    private static void writeInfo(final File file,
+                                  final ViewPanel viewPanel)
+            throws IOException {
+        final Runtime rt = Runtime.getRuntime();
+        final long used = rt.totalMemory() - rt.freeMemory();
+        final StringBuilder sb = new StringBuilder();
+        sb.append("time = ").append(LocalDateTime.now()).append('\n');
+
+        if (viewPanel != null) {
+            final Camera camera = viewPanel.getCamera();
+            final Point3D pos = camera.getTransform().getTranslation();
+            final double[] angles =
+                    camera.getTransform().getRotation().toAngles();
+            sb.append(String.format(
+                    "camera = (%.2f, %.2f, %.2f, %.2f, %.2f, %.2f)"
+                            + "  # x, y, z, yaw, pitch, roll%n",
+                    pos.x, pos.y, pos.z,
+                    angles[0], angles[1], angles[2]));
+            sb.append(String.format("view.size = %dx%d%n",
+                    viewPanel.getWidth(), viewPanel.getHeight()));
+            sb.append("measured.fps = ").append(String.format("%.1f",
+                    viewPanel.getMeasuredFPS())).append('\n');
+        }
+
+        try {
+            final GraphicsDevice device = GraphicsEnvironment
+                    .getLocalGraphicsEnvironment()
+                    .getDefaultScreenDevice();
+            sb.append(String.format("screen.resolution = %dx%d@%dHz%n",
+                    device.getDisplayMode().getWidth(),
+                    device.getDisplayMode().getHeight(),
+                    device.getDisplayMode().getRefreshRate()));
+        } catch (final Throwable t) {
+            sb.append("screen.resolution = unavailable (")
+                    .append(t.getMessage()).append(")\n");
+        }
+
+        sb.append(String.format("heap.used = %d MB%nheap.max = %d MB%n",
+                used / (1024 * 1024), rt.maxMemory() / (1024 * 1024)));
+        sb.append("telemetry = ").append(Telemetry.snapshotLine())
+                .append('\n');
+        sb.append("java.version = ")
+                .append(System.getProperty("java.version")).append('\n');
+        sb.append("os = ").append(System.getProperty("os.name"))
+                .append(' ').append(System.getProperty("os.version"))
+                .append('\n');
+        sb.append("config.ipd.cm = ").append(EngineConfig.getIpdCm())
+                .append('\n');
+        sb.append("config.bugreport.dir = ")
+                .append(EngineConfig.getBugReportDir()).append('\n');
+        sb.append("config.log.dir = ")
+                .append(EngineConfig.getLogDir()).append('\n');
+
+        Files.writeString(file.toPath(), sb.toString());
+    }
+
+    private static void writeScreenshot(final File dir,
+                                        final ViewPanel viewPanel)
+            throws IOException {
+        if (viewPanel == null)
+            return;
+        final BufferedImage lastFrame = viewPanel.getLastFrameImage();
+        if (lastFrame == null) {
+            Files.writeString(new File(dir, "screenshot-missing.txt")
+                            .toPath(),
+                    "no frame had been rendered yet\n");
+            return;
+        }
+        // the engine reuses frame buffers (triple buffering) — copy
+        final BufferedImage copy = new BufferedImage(lastFrame.getWidth(),
+                lastFrame.getHeight(), BufferedImage.TYPE_INT_RGB);
+        copy.getGraphics().drawImage(lastFrame, 0, 0, null);
+        ImageIO.write(copy, "png", new File(dir, "screenshot.png"));
+    }
+
+    private static void copyLogs(final File dir) throws IOException {
+        final File log = PersistentLog.getLogFile();
+        if (log == null || !log.isFile())
+            return;
+        Files.copy(log.toPath(),
+                new File(dir, log.getName()).toPath(),
+                StandardCopyOption.REPLACE_EXISTING);
+        final File backup = new File(log.getParentFile(),
+                log.getName() + ".1");
+        if (backup.isFile()) {
+            Files.copy(backup.toPath(),
+                    new File(dir, backup.getName()).toPath(),
+                    StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    /**
+     * Live-object histogram: forces a full GC and lists instance counts
+     * per class — the direct answer to "what is leaking". Best effort:
+     * needs the JDK's jcmd on the PATH. Bounded by a timeout: jcmd
+     * self-attach can hang in some environments, and a bug report must
+     * still complete without it.
+     */
+    private static void writeHistogram(final File dir) {
+        final String pid = String.valueOf(ProcessHandle.current().pid());
+        final File out = new File(dir, "histogram.txt");
+        try {
+            final Process process = new ProcessBuilder("jcmd", pid,
+                    "GC.class_histogram").redirectErrorStream(true)
+                    .redirectOutput(out).start();
+            final boolean done = process.waitFor(30,
+                    java.util.concurrent.TimeUnit.SECONDS);
+            if (done && process.exitValue() == 0) {
+                return;
+            }
+            if (!done) {
+                process.destroyForcibly();
+            }
+            out.delete();
+            Files.writeString(
+                    new File(dir, "histogram-unavailable.txt").toPath(),
+                    "jcmd GC.class_histogram did not finish within 30s\n");
+        } catch (final Throwable t) {
+            try {
+                Files.writeString(
+                        new File(dir, "histogram-unavailable.txt").toPath(),
+                        "jcmd GC.class_histogram failed: " + t + "\n");
+            } catch (final IOException ignored) {
+            }
+        }
+    }
+
+    private static void writeThreadDump(final File file)
+            throws IOException {
+        final StringBuilder sb = new StringBuilder();
+        for (final Map.Entry<Thread, StackTraceElement[]> entry
+                : Thread.getAllStackTraces().entrySet()) {
+            final Thread thread = entry.getKey();
+            sb.append('"').append(thread.getName()).append('"')
+                    .append(thread.isDaemon() ? " daemon" : "")
+                    .append(" state=").append(thread.getState())
+                    .append('\n');
+            for (final StackTraceElement element : entry.getValue()) {
+                sb.append("    at ").append(element).append('\n');
+            }
+            sb.append('\n');
+        }
+        sb.append("vm = ").append(ManagementFactory.getRuntimeMXBean()
+                .getVmName()).append('\n');
+        Files.writeString(file.toPath(), sb.toString());
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/Camera.java
new file mode 100644 (file)
index 0000000..a212821
--- /dev/null
@@ -0,0 +1,235 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+
+/**
+ * Represents the viewer's camera in the 3D world, with position, orientation, and movement.
+ *
+ * <p>The camera is the user's "eyes" in the 3D scene. It has a position (location),
+ * a looking direction (defined by a quaternion), and a movement system with
+ * velocity, acceleration, and friction for smooth camera navigation.</p>
+ *
+ * <p>By default, the user can navigate using arrow keys (handled by
+ * {@link eu.svjatoslav.aukio.e3d.gui.humaninput.WorldNavigationUserInputTracker}),
+ * and the mouse controls the look direction (handled by
+ * {@link eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager}).</p>
+ *
+ * <p><b>Programmatic camera control:</b></p>
+ * <pre>{@code
+ * Camera camera = viewPanel.getCamera();
+ *
+ * // Set camera position
+ * camera.getTransform().setTranslation(new Point3D(0, -50, -200));
+ *
+ * // Set camera orientation using a quaternion
+ * camera.getTransform().getRotation().set(Quaternion.fromAngles(0.5, -0.3));
+ *
+ * // Copy camera state from another camera
+ * Camera snapshot = new Camera(camera);
+ * }</pre>
+ *
+ * @see ViewPanel#getCamera()
+ * @see eu.svjatoslav.aukio.e3d.gui.humaninput.WorldNavigationUserInputTracker default keyboard navigation
+ */
+public class Camera implements FrameListener {
+
+    /**
+     * Camera movement speed limit, relative to the world. When camera coordinates are
+     * updated within the world, camera orientation relative to the world is
+     * taken into account.
+     */
+    public static final double SPEED_LIMIT = 30;
+
+    /** World units moved per millisecond per unit of velocity.
+     * Public: direct-drive controllers (SpaceMouse) reuse it to match
+     * the keyboard/mouse feel. */
+    public static final double SPEED_MULTIPLIER = .02d;
+    /**
+     * Determines amount of friction user experiences every millisecond while moving around in space.
+     */
+    private static final double MILLISECOND_FRICTION = 1.005;
+    /**
+     * Camera movement speed, relative to camera itself. When camera coordinates
+     * are updated within the world, camera orientation relative to the world is
+     * taken into account.
+     */
+    private final Point3D movementVector = new Point3D();
+    private final Point3D previousLocation = new Point3D();
+    /**
+     * Camera acceleration factor for movement speed. Higher values result in faster acceleration.
+     */
+    public double cameraAcceleration = 0.1;
+    /**
+     * The transform containing camera location and orientation.
+     */
+    private final Transform transform;
+
+    /**
+     * Creates a camera at the world origin with no rotation.
+     */
+    public Camera() {
+        transform = new Transform();
+    }
+
+    /**
+     * Creates a copy of an existing camera, cloning its position and orientation.
+     *
+     * @param sourceView the camera to copy
+     */
+    public Camera(final Camera sourceView) {
+        transform = sourceView.getTransform().clone();
+    }
+
+    /**
+     * Creates a camera with the specified transform (position and orientation).
+     *
+     * @param transform the initial transform defining position and rotation
+     */
+    public Camera(final Transform transform){
+        this.transform = transform;
+    }
+
+    @Override
+    public boolean onFrame(final ViewPanel viewPanel, final int millisecondsSinceLastFrame) {
+
+        previousLocation.clone(transform.getTranslation());
+        translateCameraLocationBasedOnMovementVector(millisecondsSinceLastFrame);
+        applyFrictionToMovement(millisecondsSinceLastFrame);
+        return isFrameRepaintNeeded();
+    }
+
+    private boolean isFrameRepaintNeeded() {
+        final double distanceMoved = transform.getTranslation().getDistanceTo(previousLocation);
+        return distanceMoved > 0.03;
+    }
+
+    /**
+     * Clamps the camera's movement speed to {@link #SPEED_LIMIT}.
+     * Called after modifying the movement vector to prevent excessive velocity.
+     */
+    public void enforceSpeedLimit() {
+        final double currentSpeed = movementVector.getVectorLength();
+
+        if (currentSpeed <= SPEED_LIMIT)
+            return;
+
+        movementVector.divide(currentSpeed / SPEED_LIMIT);
+    }
+
+    /**
+     * Returns the current movement velocity vector, relative to the camera's orientation.
+     * Modify this vector to programmatically move the camera.
+     *
+     * @return the movement vector (mutable reference)
+     */
+    public Point3D getMovementVector() {
+        return movementVector;
+    }
+
+    /**
+     * Returns the current movement speed (magnitude of the movement vector).
+     *
+     * @return the scalar speed value
+     */
+    public double getMovementSpeed() {
+        return movementVector.getVectorLength();
+    }
+
+    /**
+     * Apply friction to camera movement vector.
+     *
+     * @param millisecondsPassedSinceLastFrame We want camera movement to be independent of framerate.
+     *                                         Therefore, we take frame rendering time into account when translating
+     *                                         camera between consecutive frames.
+     */
+    private void applyFrictionToMovement(int millisecondsPassedSinceLastFrame) {
+        for (int i = 0; i < millisecondsPassedSinceLastFrame; i++)
+            applyMillisecondFrictionToUserMovementVector();
+    }
+
+    /**
+     * Apply friction to camera movement vector.
+     */
+    private void applyMillisecondFrictionToUserMovementVector() {
+        movementVector.x /= MILLISECOND_FRICTION;
+        movementVector.y /= MILLISECOND_FRICTION;
+        movementVector.z /= MILLISECOND_FRICTION;
+    }
+
+    /**
+     * Translate coordinates based on camera movement vector and camera orientation in the world.
+     *
+     * @param millisecondsPassedSinceLastFrame We want camera movement to be independent of framerate.
+     *                                         Therefore, we take frame rendering time into account when translating
+     *                                         camera between consecutive frames.
+     */
+    private void translateCameraLocationBasedOnMovementVector(int millisecondsPassedSinceLastFrame) {
+        final Matrix3x3 m = transform.getRotation().toMatrix();
+
+        final double forwardX = m.m20;
+        final double forwardY = m.m21;
+        final double forwardZ = m.m22;
+
+        final double rightX = m.m00;
+        final double rightY = m.m01;
+        final double rightZ = m.m02;
+
+        final Point3D location = transform.getTranslation();
+        final double ms = millisecondsPassedSinceLastFrame;
+
+        location.x += forwardX * movementVector.z * SPEED_MULTIPLIER * ms;
+        location.y += forwardY * movementVector.z * SPEED_MULTIPLIER * ms;
+        location.z += forwardZ * movementVector.z * SPEED_MULTIPLIER * ms;
+
+        location.x += rightX * movementVector.x * SPEED_MULTIPLIER * ms;
+        location.y += rightY * movementVector.x * SPEED_MULTIPLIER * ms;
+        location.z += rightZ * movementVector.x * SPEED_MULTIPLIER * ms;
+
+        location.y += movementVector.y * SPEED_MULTIPLIER * ms;
+    }
+
+    /**
+     * Returns the transform containing this camera's location and orientation.
+     *
+     * @return the transform (mutable reference)
+     */
+    public Transform getTransform() {
+        return transform;
+    }
+
+    /**
+     * Orients the camera to look at a target point in world coordinates.
+     *
+     * <p>Calculates the required XZ and YZ rotation angles to point the camera
+     * from its current position toward the target. Useful for programmatic
+     * camera control, cinematic sequences, and following objects.</p>
+     *
+     * <p><b>Example:</b></p>
+     * <pre>{@code
+     * Camera camera = viewPanel.getCamera();
+     * camera.getTransform().setTranslation(new Point3D(100, -50, -200));
+     * camera.lookAt(new Point3D(0, 0, 0));  // Point camera at origin
+     * }</pre>
+     *
+     * @param target the world-space point to look at
+     */
+    public void lookAt(final Point3D target) {
+        final Point3D pos = transform.getTranslation();
+        final double dx = target.x - pos.x;
+        final double dy = target.y - pos.y;
+        final double dz = target.z - pos.z;
+
+        final double angleXZ = -Math.atan2(dx, dz);
+        final double horizontalDist = Math.sqrt(dx * dx + dz * dz);
+        final double angleYZ = -Math.atan2(dy, horizontalDist);
+
+        transform.getRotation().set(Quaternion.fromAngles(angleXZ, angleYZ));
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/CullingStatistics.java
new file mode 100644 (file)
index 0000000..3b112c5
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Statistics for frustum culling, tracking composite-level culling efficiency.
+ *
+ * <p>Updated each frame during the rendering pipeline:</p>
+ * <ul>
+ *   <li>{@link #totalComposites} - incremented before each composite's frustum test</li>
+ *   <li>{@link #culledComposites} - incremented when a composite fails the frustum test</li>
+ * </ul>
+ *
+ * <p>Thread safety: counters are {@link AtomicInteger} because the parallel
+ * transform phase increments them from multiple worker threads.</p>
+ *
+ * <p>Displayed in the {@link DeveloperToolsPanel} to help developers understand
+ * culling efficiency and optimize scene graphs.</p>
+ *
+ * @see DeveloperToolsPanel
+ * @see eu.svjatoslav.aukio.e3d.geometry.Frustum
+ */
+public class CullingStatistics {
+
+    /**
+     * Total number of composite shapes tested against the frustum this frame.
+     * Incremented before each composite's AABB frustum test.
+     * Does not include the root composite (which is never frustum-tested).
+     */
+    public final AtomicInteger totalComposites = new AtomicInteger(0);
+
+    /**
+     * Number of composite shapes that were entirely outside the frustum and skipped.
+     * When a composite is culled, all its children (shapes and nested composites)
+     * are skipped without individual testing.
+     */
+    public final AtomicInteger culledComposites = new AtomicInteger(0);
+
+    /**
+     * Resets all statistics to zero.
+     * Called at the start of each frame before computing new statistics.
+     */
+    public void reset() {
+        totalComposites.set(0);
+        culledComposites.set(0);
+    }
+
+    /**
+     * Returns the percentage of composites that were culled.
+     *
+     * @return the culled percentage (0-100), or 0 if there are no composites
+     */
+    public double getCulledPercentage() {
+        final int total = totalComposites.get();
+        if (total == 0) {
+            return 0.0;
+        }
+        return 100.0 * culledComposites.get() / total;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DebugLogBuffer.java
new file mode 100644 (file)
index 0000000..5104b37
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Circular buffer for debug log messages.
+ *
+ * <p>Captures log messages to a fixed-size circular buffer for display
+ * in the {@link DeveloperToolsPanel}.</p>
+ *
+ * <p>This allows capturing early initialization logs before the user opens
+ * the Developer Tools panel. When the panel is opened, the buffered history
+ * becomes immediately visible.</p>
+ *
+ * @see DeveloperToolsPanel
+ */
+public class DebugLogBuffer {
+
+    private static final DateTimeFormatter TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
+
+    private final String[] buffer;
+    private final int capacity;
+    private volatile int head = 0;
+    private volatile int count = 0;
+
+    /**
+     * Creates a new DebugLogBuffer with the specified capacity.
+     *
+     * @param capacity the maximum number of log entries to retain
+     */
+    public DebugLogBuffer(final int capacity) {
+        this.capacity = capacity;
+        this.buffer = new String[capacity];
+    }
+
+    /**
+     * Logs a message with a timestamp prefix.
+     *
+     * @param message the message to log
+     */
+    public void log(final String message) {
+        final String timestamped = LocalDateTime.now().format(TIME_FORMATTER) + " " + message;
+
+        synchronized (this) {
+            buffer[head] = timestamped;
+            head = (head + 1) % capacity;
+            if (count < capacity) {
+                count++;
+            }
+        }
+    }
+
+    /**
+     * Returns all buffered log entries in chronological order.
+     *
+     * @return a list of timestamped log entries
+     */
+    public synchronized List<String> getEntries() {
+        final List<String> entries = new ArrayList<>(count);
+
+        if (count < capacity) {
+            for (int i = 0; i < count; i++) {
+                entries.add(buffer[i]);
+            }
+        } else {
+            for (int i = 0; i < capacity; i++) {
+                final int index = (head + i) % capacity;
+                entries.add(buffer[index]);
+            }
+        }
+
+        return entries;
+    }
+
+    /**
+     * Clears all buffered log entries.
+     */
+    public synchronized void clear() {
+        head = 0;
+        count = 0;
+    }
+
+    /**
+     * Returns the current number of log entries in the buffer.
+     *
+     * @return the number of entries
+     */
+    public synchronized int size() {
+        return count;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperTools.java
new file mode 100644 (file)
index 0000000..4813daf
--- /dev/null
@@ -0,0 +1,46 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+/**
+ * Per-ViewPanel developer tools that control diagnostic features.
+ *
+ * <p>Each {@link ViewPanel} has its own DeveloperTools instance, allowing
+ * different views to have independent debug configurations.</p>
+ *
+ * <p>Settings can be toggled at runtime via the {@link DeveloperToolsPanel}
+ * (opened with F12 key).</p>
+ *
+ * @see ViewPanel#getDeveloperTools()
+ * @see DeveloperToolsPanel
+ */
+public class DeveloperTools {
+
+    /**
+     * If {@code true}, textured polygon borders are drawn in yellow.
+     * Useful for visualizing polygon slicing for perspective-correct rendering.
+     */
+    public volatile boolean showPolygonBorders = false;
+
+    /**
+     * If {@code true}, only render even-numbered horizontal segments (0, 2, 4, 6).
+     * Odd segments (1, 3, 5, 7) will remain black. Useful for detecting
+     * if threads render outside their allocated screen area (overdraw detection).
+     */
+    public volatile boolean renderAlternateSegments = false;
+
+    /**
+     * If {@code true}, draws red horizontal lines at segment boundaries.
+     * Useful for visualizing which thread renders which screen area.
+     * Each line marks the boundary between two adjacent rendering segments.
+     */
+    public volatile boolean showSegmentBoundaries = false;
+
+    /**
+     * Creates a new DeveloperTools instance with all debug features disabled.
+     */
+    public DeveloperTools() {
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeveloperToolsPanel.java
new file mode 100644 (file)
index 0000000..b8b5a3a
--- /dev/null
@@ -0,0 +1,646 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+import javax.swing.*;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import java.awt.*;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.util.List;
+
+/**
+ * Developer tools panel for toggling diagnostic features and viewing logs.
+ *
+ * <p>Opens as a popup window when F12 is pressed. Provides:</p>
+ * <ul>
+ *   <li>Checkboxes to toggle debug settings</li>
+ *   <li>Camera position display with copy button</li>
+ *   <li>Composite shape frustum culling statistics</li>
+ *   <li>A scrollable log viewer showing captured debug output</li>
+ *   <li>A button to clear the log buffer</li>
+ *   <li>Resizable window with native maximize support</li>
+ * </ul>
+ *
+ * @see DeveloperTools
+ * @see DebugLogBuffer
+ */
+public class DeveloperToolsPanel extends JFrame {
+
+    private static final int UPDATE_INTERVAL_MS = 200;
+
+    /**
+     * The view panel whose camera is being displayed.
+     */
+    private final ViewPanel viewPanel;
+    /**
+     * The developer tools being controlled.
+     */
+    private final DeveloperTools developerTools;
+    /**
+     * The log buffer being displayed.
+     */
+    private final DebugLogBuffer debugLogBuffer;
+    /**
+     * The text area showing log messages.
+     */
+    private final JTextArea logArea;
+    /**
+     * The label showing camera position.
+     */
+    private final JLabel cameraLabel;
+    /**
+     * The label showing total composites count.
+     */
+    private final JLabel totalCompositesLabel;
+    /**
+     * The label showing culled composites count.
+     */
+    private final JLabel culledCompositesLabel;
+    /**
+     * The label showing culled percentage.
+     */
+    private final JLabel culledPercentLabel;
+    /**
+     * The label showing current render thread count.
+     */
+    private final JLabel renderThreadsLabel;
+    /**
+     * The label showing the current target FPS.
+     */
+    private final JLabel targetFpsLabel;
+    /**
+     * The label showing the measured FPS.
+     */
+    private final JLabel measuredFpsLabel;
+    /**
+     * Toggle button switching between target FPS and unlimited FPS.
+     */
+    private final JToggleButton unlockFpsButton;
+    /**
+     * The per-thread activity timeline.
+     */
+    private final ThreadTimelineComponent threadTimeline;
+    /**
+     * Toggle button enabling thread activity recording.
+     */
+    private final JToggleButton recordTimelineButton;
+    /**
+     * Target FPS to restore when re-locking after an unlock.
+     */
+    private int lockedFPS = 60;
+    /**
+     * Timer for periodic updates.
+     */
+    private final Timer updateTimer;
+    /**
+     * Flag to prevent concurrent updates.
+     */
+    private volatile boolean updating = false;
+
+    /**
+     * Creates and displays a developer tools panel.
+     *
+     * @param parent         the parent frame (for centering)
+     * @param viewPanel      the view panel whose camera to display
+     * @param developerTools the developer tools to control
+     * @param debugLogBuffer the log buffer to display
+     */
+    public DeveloperToolsPanel(final Frame parent, final ViewPanel viewPanel,
+                               final DeveloperTools developerTools,
+                               final DebugLogBuffer debugLogBuffer) {
+        super("Developer Tools");
+        this.viewPanel = viewPanel;
+        this.developerTools = developerTools;
+        this.debugLogBuffer = debugLogBuffer;
+
+        setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+        setLayout(new BorderLayout(8, 8));
+
+        cameraLabel = new JLabel(" ");
+        cameraLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+
+        // Initialize culling statistics labels
+        totalCompositesLabel = new JLabel("0");
+        totalCompositesLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        culledCompositesLabel = new JLabel("0");
+        culledCompositesLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        culledPercentLabel = new JLabel("0.0%");
+        culledPercentLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+
+        // Initialize render threads label
+        renderThreadsLabel = new JLabel(String.valueOf(viewPanel.getNumRenderThreads()));
+        renderThreadsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+
+        // Initialize frame rate display and unlock toggle
+        targetFpsLabel = new JLabel(" ");
+        targetFpsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        measuredFpsLabel = new JLabel(" ");
+        measuredFpsLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        unlockFpsButton = new JToggleButton("Unlock FPS");
+        unlockFpsButton.setToolTipText(
+                "Disable the frame rate cap so the application renders as fast as it can (benchmark mode)");
+        unlockFpsButton.setSelected(viewPanel.getTargetFPS() <= 0);
+        unlockFpsButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                if (unlockFpsButton.isSelected()) {
+                    final int current = viewPanel.getTargetFPS();
+                    if (current > 0) {
+                        lockedFPS = current;
+                    }
+                    viewPanel.setFrameRate(0);
+                } else {
+                    viewPanel.setFrameRate(lockedFPS);
+                }
+                updateFrameRateLabels();
+            }
+        });
+
+        // Thread activity timeline with record toggle
+        threadTimeline = new ThreadTimelineComponent();
+        recordTimelineButton = new JToggleButton("Record");
+        recordTimelineButton.setToolTipText(
+                "Record per-thread activity (transform/paint/binning per frame, idle = black) for the timeline below");
+        recordTimelineButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                ThreadActivityRecorder.setEnabled(recordTimelineButton.isSelected());
+            }
+        });
+
+        final JPanel topPanel = new JPanel();
+        topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS));
+        topPanel.add(createSettingsPanel());
+        topPanel.add(createCameraPanel());
+        topPanel.add(createCullingPanel());
+        topPanel.add(createRenderThreadsPanel());
+        topPanel.add(createFrameRatePanel());
+        topPanel.add(createThreadTimelinePanel());
+        add(topPanel, BorderLayout.NORTH);
+
+        logArea = new JTextArea(15, 60);
+        logArea.setEditable(false);
+        logArea.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        logArea.setBackground(Color.BLACK);
+        logArea.setForeground(Color.GREEN);
+        final JScrollPane scrollPane = new JScrollPane(logArea);
+        scrollPane.setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
+        add(scrollPane, BorderLayout.CENTER);
+
+        final JPanel buttonPanel = createButtonPanel();
+        add(buttonPanel, BorderLayout.SOUTH);
+
+        pack();
+        setLocationRelativeTo(parent);
+
+        updateTimer = new Timer(UPDATE_INTERVAL_MS, new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                updateDisplay();
+            }
+        });
+
+        addWindowListener(new WindowAdapter() {
+            @Override
+            public void windowOpened(final WindowEvent e) {
+                updateDisplay();
+                updateTimer.start();
+            }
+
+            @Override
+            public void windowClosed(final WindowEvent e) {
+                updateTimer.stop();
+            }
+        });
+    }
+
+    private JPanel createSettingsPanel() {
+        final JPanel panel = new JPanel(new GridLayout(0, 1, 0, 2));
+        panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
+
+        final JCheckBox showBordersCheckbox = new JCheckBox("Show polygon borders");
+        showBordersCheckbox.setSelected(developerTools.showPolygonBorders);
+        showBordersCheckbox.addChangeListener(new ChangeListener() {
+            @Override
+            public void stateChanged(final ChangeEvent e) {
+                developerTools.showPolygonBorders = showBordersCheckbox.isSelected();
+            }
+        });
+
+        final JCheckBox alternateSegmentsCheckbox = new JCheckBox("Render alternate segments (overdraw debug)");
+        alternateSegmentsCheckbox.setSelected(developerTools.renderAlternateSegments);
+        alternateSegmentsCheckbox.addChangeListener(new ChangeListener() {
+            @Override
+            public void stateChanged(final ChangeEvent e) {
+                developerTools.renderAlternateSegments = alternateSegmentsCheckbox.isSelected();
+            }
+        });
+
+        final JCheckBox segmentBoundariesCheckbox = new JCheckBox("Show segment boundaries");
+        segmentBoundariesCheckbox.setSelected(developerTools.showSegmentBoundaries);
+        segmentBoundariesCheckbox.addChangeListener(new ChangeListener() {
+            @Override
+            public void stateChanged(final ChangeEvent e) {
+                developerTools.showSegmentBoundaries = segmentBoundariesCheckbox.isSelected();
+            }
+        });
+
+        panel.add(showBordersCheckbox);
+        panel.add(alternateSegmentsCheckbox);
+        panel.add(segmentBoundariesCheckbox);
+
+        return panel;
+    }
+
+    private JPanel createCameraPanel() {
+        final JPanel panel = new JPanel(new BorderLayout(4, 4));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(8, 8, 8, 8),
+                BorderFactory.createTitledBorder("Camera (x, y, z, yaw, pitch, roll)")
+        ));
+
+        panel.add(cameraLabel, BorderLayout.CENTER);
+
+        final JButton copyButton = new JButton("Copy");
+        copyButton.setToolTipText("Copy camera position to clipboard");
+        copyButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                final String text = cameraLabel.getText();
+                if (text != null && !text.trim().isEmpty()) {
+                    final StringSelection sel = new StringSelection(text);
+                    Toolkit.getDefaultToolkit().getSystemClipboard().setContents(sel, null);
+                }
+            }
+        });
+
+        final JButton pasteButton = new JButton("Paste");
+        pasteButton.setToolTipText(
+                "Set camera position from clipboard (six comma-separated"
+                        + " numbers: x, y, z, yaw, pitch, roll); does"
+                        + " nothing if the clipboard holds anything else");
+        pasteButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                pasteCameraPosition();
+            }
+        });
+
+        final JPanel buttons = new JPanel(new GridLayout(0, 1, 0, 4));
+        buttons.add(copyButton);
+        buttons.add(pasteButton);
+        panel.add(buttons, BorderLayout.EAST);
+
+        return panel;
+    }
+
+    /**
+     * Strict decimal number: rejects NaN/Infinity/hex-float spellings
+     * that {@link Double#parseDouble(String)} would otherwise accept.
+     */
+    private static final java.util.regex.Pattern NUMBER =
+            java.util.regex.Pattern.compile(
+                    "[+-]?(\\d+\\.?\\d*|\\.\\d+)([eE][+-]?\\d+)?");
+
+    /**
+     * Reads the system clipboard and, if it holds exactly six
+     * comma-separated numbers (the format {@link #updateCameraLabel()}
+     * and the Copy button produce), teleports the camera to
+     * x, y, z, yaw, pitch, roll. Anything else in the clipboard is
+     * ignored silently.
+     */
+    private void pasteCameraPosition() {
+        if (viewPanel == null) {
+            return;
+        }
+        final String text;
+        try {
+            final java.awt.datatransfer.Clipboard clipboard =
+                    Toolkit.getDefaultToolkit().getSystemClipboard();
+            if (!clipboard.isDataFlavorAvailable(
+                    java.awt.datatransfer.DataFlavor.stringFlavor)) {
+                return;
+            }
+            text = (String) clipboard.getData(
+                    java.awt.datatransfer.DataFlavor.stringFlavor);
+        } catch (final Exception ex) {
+            return;
+        }
+        if (text == null) {
+            return;
+        }
+        final String[] parts = text.trim().split(",", -1);
+        if (parts.length != 6) {
+            return;
+        }
+        final double[] values = new double[6];
+        for (int i = 0; i < 6; i++) {
+            final String token = parts[i].trim();
+            if (!NUMBER.matcher(token).matches()) {
+                return;
+            }
+            values[i] = Double.parseDouble(token);
+        }
+        viewPanel.getCamera().getTransform().set(values[0], values[1],
+                values[2], values[3], values[4], values[5]);
+        updateCameraLabel();
+    }
+
+    private JPanel createCullingPanel() {
+        final JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(0, 8, 8, 8),
+                BorderFactory.createTitledBorder("Composite shape frustum culling")
+        ));
+
+        // Single row: total, culled, percent
+        final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+        statsRow.add(new JLabel("Total:"));
+        statsRow.add(totalCompositesLabel);
+        statsRow.add(new JLabel("   Culled:"));
+        statsRow.add(culledCompositesLabel);
+        statsRow.add(culledPercentLabel);
+
+        panel.add(statsRow);
+
+        return panel;
+    }
+
+    private JPanel createRenderThreadsPanel() {
+        final JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(0, 8, 8, 8),
+                BorderFactory.createTitledBorder("Render Threads")
+        ));
+
+        final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+        statsRow.add(new JLabel("Active:"));
+        statsRow.add(renderThreadsLabel);
+        statsRow.add(new JLabel("   Available cores:"));
+        statsRow.add(new JLabel(String.valueOf(Runtime.getRuntime().availableProcessors())));
+
+        panel.add(statsRow);
+
+        return panel;
+    }
+
+    private JPanel createFrameRatePanel() {
+        final JPanel panel = new JPanel();
+        panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(0, 8, 8, 8),
+                BorderFactory.createTitledBorder("Frame Rate")
+        ));
+
+        final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+        statsRow.add(new JLabel("Target:"));
+        statsRow.add(targetFpsLabel);
+        statsRow.add(new JLabel("   Measured:"));
+        statsRow.add(measuredFpsLabel);
+        statsRow.add(unlockFpsButton);
+
+        panel.add(statsRow);
+
+        return panel;
+    }
+
+    private JPanel createThreadTimelinePanel() {
+        final JPanel panel = new JPanel(new BorderLayout(4, 4));
+        panel.setBorder(BorderFactory.createCompoundBorder(
+                BorderFactory.createEmptyBorder(0, 8, 8, 8),
+                BorderFactory.createTitledBorder("Thread Timeline (idle = black; wheel = scroll, ctrl+wheel = zoom)")
+        ));
+        panel.add(recordTimelineButton, BorderLayout.NORTH);
+        panel.add(threadTimeline, BorderLayout.CENTER);
+        panel.add(threadTimeline.scrollBar, BorderLayout.SOUTH);
+        return panel;
+    }
+
+    private JPanel createButtonPanel() {
+        final JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+
+        final JButton clearButton = new JButton("Clear Logs");
+        clearButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                debugLogBuffer.clear();
+                logArea.setText("");
+            }
+        });
+
+        final JButton bugReportButton = new JButton("Make Bug Report...");
+        bugReportButton.setToolTipText(
+                "Write a bug report directory: your description, a"
+                        + " screenshot, camera position, logs and memory"
+                        + " statistics");
+        bugReportButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                showBugReportDialog();
+            }
+        });
+
+        panel.add(clearButton);
+        panel.add(bugReportButton);
+
+        return panel;
+    }
+
+    /**
+     * Popup asking what happened, then writes a bug report directory
+     * (screenshot + description + logs + memory stats) and shows the
+     * resulting path.
+     */
+    private void showBugReportDialog() {
+        final JTextArea descriptionArea = new JTextArea(8, 50);
+        descriptionArea.setLineWrap(true);
+        descriptionArea.setWrapStyleWord(true);
+        final JScrollPane scroll = new JScrollPane(descriptionArea);
+        scroll.setBorder(BorderFactory.createTitledBorder(
+                "What happened / what looks wrong?"));
+
+        final int choice = JOptionPane.showConfirmDialog(this, scroll,
+                "Make Bug Report", JOptionPane.OK_CANCEL_OPTION,
+                JOptionPane.PLAIN_MESSAGE);
+        if (choice != JOptionPane.OK_OPTION)
+            return;
+
+        try {
+            final java.io.File dir = BugReport.create(viewPanel,
+                    descriptionArea.getText());
+            showBugReportResultDialog(dir);
+        } catch (final Exception ex) {
+            JOptionPane.showMessageDialog(this,
+                    "Bug report failed: " + ex.getMessage(),
+                    "Bug Report", JOptionPane.ERROR_MESSAGE);
+        }
+    }
+
+    /**
+     * Shows the created bug report's location in a selectable text
+     * field (copy-paste with Ctrl+C works) plus explicit Copy Path and
+     * Open Folder buttons — a plain JOptionPane message is neither
+     * selectable nor actionable.
+     */
+    private void showBugReportResultDialog(final java.io.File dir) {
+        final JDialog dialog = new JDialog(this, "Bug Report", true);
+        dialog.setLayout(new BorderLayout(8, 8));
+
+        final JPanel centerPanel = new JPanel(new BorderLayout(4, 4));
+        centerPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 0, 8));
+        centerPanel.add(new JLabel(
+                "Bug report written to (point the developer at this"
+                        + " directory):"), BorderLayout.NORTH);
+        final JTextField pathField = new JTextField(
+                dir.getAbsolutePath());
+        pathField.setEditable(false);
+        pathField.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+        centerPanel.add(pathField, BorderLayout.CENTER);
+        dialog.add(centerPanel, BorderLayout.CENTER);
+
+        final JPanel buttonPanel = new JPanel(
+                new FlowLayout(FlowLayout.LEFT));
+
+        final JButton copyButton = new JButton("Copy Path");
+        copyButton.setToolTipText("Copy the report path to the clipboard");
+        copyButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                final StringSelection sel = new StringSelection(
+                        dir.getAbsolutePath());
+                Toolkit.getDefaultToolkit().getSystemClipboard()
+                        .setContents(sel, null);
+            }
+        });
+        buttonPanel.add(copyButton);
+
+        final boolean openSupported = Desktop.isDesktopSupported()
+                && Desktop.getDesktop()
+                        .isSupported(Desktop.Action.OPEN);
+        final JButton openButton = new JButton("Open Folder");
+        openButton.setToolTipText(openSupported
+                ? "Open the report directory in the file manager"
+                : "Opening folders is not supported on this desktop");
+        openButton.setEnabled(openSupported);
+        if (openSupported) {
+            openButton.addActionListener(new ActionListener() {
+                @Override
+                public void actionPerformed(final ActionEvent e) {
+                    try {
+                        Desktop.getDesktop().open(dir);
+                    } catch (final Exception ex) {
+                        JOptionPane.showMessageDialog(dialog,
+                                "Could not open folder: "
+                                        + ex.getMessage(),
+                                "Bug Report", JOptionPane.ERROR_MESSAGE);
+                    }
+                }
+            });
+        }
+        buttonPanel.add(openButton);
+
+        final JButton closeButton = new JButton("Close");
+        closeButton.addActionListener(new ActionListener() {
+            @Override
+            public void actionPerformed(final ActionEvent e) {
+                dialog.dispose();
+            }
+        });
+        buttonPanel.add(closeButton);
+
+        dialog.add(buttonPanel, BorderLayout.SOUTH);
+        dialog.pack();
+        dialog.setLocationRelativeTo(this);
+        dialog.setVisible(true);
+    }
+
+    private void updateDisplay() {
+        if (updating) {
+            return;
+        }
+        updating = true;
+        try {
+            updateCameraLabel();
+            updateCullingStatistics();
+            updateRenderThreadsLabel();
+            updateFrameRateLabels();
+            updateLogDisplay();
+            threadTimeline.repaint();
+        } finally {
+            updating = false;
+        }
+    }
+
+    private void updateCameraLabel() {
+        if (viewPanel == null) {
+            return;
+        }
+
+        final Camera camera = viewPanel.getCamera();
+        final Point3D pos = camera.getTransform().getTranslation();
+        final double[] angles = camera.getTransform().getRotation().toAngles();
+
+        cameraLabel.setText(String.format("%.2f, %.2f, %.2f, %.2f, %.2f, %.2f",
+                pos.x, pos.y, pos.z, angles[0], angles[1], angles[2]));
+    }
+
+    private void updateCullingStatistics() {
+        if (viewPanel == null) {
+            return;
+        }
+
+        // Get the current rendering context from view panel's last render
+        final RenderingContext context = viewPanel.getRenderingContext();
+        if (context == null || context.cullingStatistics == null) {
+            totalCompositesLabel.setText("-");
+            culledCompositesLabel.setText("-");
+            culledPercentLabel.setText("-");
+            return;
+        }
+
+        final CullingStatistics stats = context.cullingStatistics;
+        totalCompositesLabel.setText(String.valueOf(stats.totalComposites.get()));
+        culledCompositesLabel.setText(String.valueOf(stats.culledComposites.get()));
+        culledPercentLabel.setText(String.format(" (%.1f%%)", stats.getCulledPercentage()));
+    }
+
+    private void updateRenderThreadsLabel() {
+        if (viewPanel == null) {
+            return;
+        }
+        renderThreadsLabel.setText(String.valueOf(viewPanel.getNumRenderThreads()));
+    }
+
+    private void updateFrameRateLabels() {
+        if (viewPanel == null) {
+            return;
+        }
+        final int target = viewPanel.getTargetFPS();
+        targetFpsLabel.setText(target > 0 ? String.valueOf(target) : "unlimited");
+        measuredFpsLabel.setText(String.format("%.1f", viewPanel.getMeasuredFPS()));
+    }
+
+    private void updateLogDisplay() {
+        final List<String> entries = debugLogBuffer.getEntries();
+        final StringBuilder sb = new StringBuilder();
+        for (final String entry : entries) {
+            sb.append(entry).append('\n');
+        }
+        logArea.setText(sb.toString());
+
+        final JScrollBar vertical = ((JScrollPane) logArea.getParent().getParent())
+                .getVerticalScrollBar();
+        vertical.setValue(vertical.getMaximum());
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/FrameListener.java
new file mode 100644 (file)
index 0000000..f7f12b9
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+/**
+ * Listener interface for per-frame callbacks before the 3D scene is rendered.
+ *
+ * <p>Implement this interface and register it with
+ * {@link ViewPanel#addFrameListener(FrameListener)} to receive a callback
+ * before each frame. This is the primary mechanism for implementing animations,
+ * physics updates, and other time-dependent behavior.</p>
+ *
+ * <p><b>Usage example - animating a shape:</b></p>
+ * <pre>{@code
+ * viewPanel.addFrameListener((panel, deltaMs) -> {
+ *     // Rotate the shape a little each frame
+ *     double angleIncrement = deltaMs * 0.001;  // radians per millisecond
+ *     myShape.setTransform(new Transform(
+ *         myShape.getLocation(),
+ *         currentAngle += angleIncrement, 0
+ *     ));
+ *     return true;  // request repaint since we changed something
+ * });
+ * }</pre>
+ *
+ * <p>The engine uses the return values to optimize rendering: if no listener
+ * returns {@code true} and no other changes occurred, the frame is skipped
+ * to save CPU and energy.</p>
+ *
+ * @see ViewPanel#addFrameListener(FrameListener)
+ * @see ViewPanel#removeFrameListener(FrameListener)
+ */
+public interface FrameListener {
+
+    /**
+     * Called before each frame render, allowing the listener to update state
+     * and indicate whether a repaint is needed.
+     *
+     * <p>Each registered listener is called exactly once per frame tick.
+     * The frame is only rendered if at least one listener returns {@code true}
+     * (or if the view was explicitly marked for repaint).</p>
+     *
+     * @param viewPanel                  the view panel being rendered
+     * @param millisecondsSinceLastFrame time elapsed since the previous frame,
+     *                                    for frame-rate-independent updates
+     * @return {@code true} if the view should be re-rendered this frame,
+     *         {@code false} if this listener has no visual changes
+     */
+    boolean onFrame(ViewPanel viewPanel, int millisecondsSinceLastFrame);
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/GuiComponent.java
new file mode 100644 (file)
index 0000000..a86a807
--- /dev/null
@@ -0,0 +1,208 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardHelper;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardInputHandler;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * Base class for interactive GUI components rendered in 3D space.
+ *
+ * <p>{@code GuiComponent} combines a composite shape with keyboard and mouse interaction
+ * handling. When clicked, it acquires keyboard focus (via the {@link eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack}),
+ * and a red wireframe border is displayed to indicate focus. Pressing ESC releases focus.</p>
+ *
+ * <p>This class is the foundation for interactive widgets like the
+ * {@link eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent}.</p>
+ *
+ * <p><b>Usage example - creating a custom GUI component:</b></p>
+ * <pre>{@code
+ * GuiComponent myWidget = new GuiComponent(
+ *     new Transform(new Point3D(0, 0, 300)),
+ *     viewPanel,
+ *     new Point3D(400, 300, 0)  // width, height, depth
+ * );
+ *
+ * // Add visual content to the widget
+ * myWidget.addShape(someTextCanvas);
+ *
+ * // Add to the scene
+ * viewPanel.getRootShapeCollection().addShape(myWidget);
+ * }</pre>
+ *
+ * @see eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack manages which component has keyboard focus
+ * @see eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent a full text editor built on this class
+ */
+public class GuiComponent extends AbstractCompositeShape implements
+        KeyboardInputHandler, MouseInteractionController {
+
+    private static final String GROUP_GUI_FOCUS = "gui.focus";
+
+    /**
+     * The view panel this component is attached to.
+     */
+    public final ViewPanel viewPanel;
+    Box containingBox = new Box();
+    private WireframeBox borders = null;
+
+    private boolean borderShown = false;
+
+    /**
+     * Creates a GUI component with the specified transform, view panel, and bounding box size.
+     *
+     * @param transform the position and orientation of the component in 3D space
+     * @param viewPanel the view panel this component belongs to
+     * @param size      the bounding box dimensions (width, height, depth)
+     */
+    public GuiComponent(final Transform transform,
+                        final ViewPanel viewPanel, final Point3D size) {
+        super(transform);
+        this.viewPanel = viewPanel;
+        setDimensions(size);
+    }
+
+    private WireframeBox createBorder() {
+        final LineAppearance appearance = new LineAppearance(10,
+                new eu.svjatoslav.aukio.e3d.renderer.raster.Color(255, 0, 0, 100));
+
+        final double borderSize = 10;
+
+        final Box borderArea = containingBox.clone().enlarge(borderSize);
+
+        return new WireframeBox(borderArea, appearance);
+    }
+
+    @Override
+    public boolean focusLost(final ViewPanel viewPanel) {
+        hideBorder();
+        return true;
+    }
+
+    @Override
+    public boolean focusReceived(final ViewPanel viewPanel) {
+        showBorder();
+        return true;
+    }
+
+    /**
+     * Returns whether this component currently holds keyboard focus.
+     *
+     * @return {@code true} when focused (focus border is shown)
+     */
+    public boolean hasKeyboardFocus() {
+        return borderShown;
+    }
+
+    /**
+     * Returns the wireframe border box for this component.
+     *
+     * @return the border wireframe box
+     */
+    public WireframeBox getBorders() {
+        if (borders == null)
+            borders = createBorder();
+        return borders;
+    }
+
+    /**
+     * Returns the depth of this component's bounding box.
+     *
+     * @return the depth in pixels
+     */
+    public int getDepth() {
+        return (int) containingBox.getDepth();
+    }
+
+    /**
+     * Returns the height of this component's bounding box.
+     *
+     * @return the height in pixels
+     */
+    public int getHeight() {
+        return (int) containingBox.getHeight();
+    }
+
+    /**
+     * Returns the width of this component's bounding box.
+     *
+     * @return the width in pixels
+     */
+    public int getWidth() {
+        return (int) containingBox.getWidth();
+    }
+
+    /**
+     * Hides the focus border around this component.
+     */
+    public void hideBorder() {
+        if (!borderShown)
+            return;
+        borderShown = false;
+        removeGroup(GROUP_GUI_FOCUS);
+    }
+
+    @Override
+    public boolean keyPressed(final KeyEvent event, final ViewPanel viewPanel) {
+        if (event.getKeyChar() == KeyboardHelper.ESC)
+            viewPanel.getKeyboardFocusStack().popFocusOwner();
+        return true;
+    }
+
+    @Override
+    public boolean keyReleased(final KeyEvent event, final ViewPanel viewPanel) {
+        return false;
+    }
+
+    @Override
+    public boolean mouseClicked(int button) {
+        if (button == MouseEvent.BUTTON_MIDDLE) {
+            // middle click releases keyboard focus, like ESC
+            viewPanel.getKeyboardFocusStack().popFocusOwner();
+            return true;
+        }
+        return viewPanel.getKeyboardFocusStack().pushFocusOwner(this);
+    }
+
+    @Override
+    public boolean mouseWheelMoved(final int verticalUnits,
+                                   final int horizontalUnits) {
+        // a focused GUI component owns the scroll wheel (the camera must
+        // not move while a component is focused); subclasses like the
+        // terminal and browser panels forward the scroll to their app
+        return true;
+    }
+
+    @Override
+    public boolean mouseEntered() {
+        return false;
+    }
+
+    @Override
+    public boolean mouseExited() {
+        return false;
+    }
+
+    private void setDimensions(final Point3D size) {
+        containingBox.setBoxSize(size);
+    }
+
+    private void showBorder() {
+        if (borderShown)
+            return;
+        borderShown = true;
+        addShape(getBorders(), GROUP_GUI_FOCUS);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/HiZPyramid.java
new file mode 100644 (file)
index 0000000..a12608b
--- /dev/null
@@ -0,0 +1,198 @@
+package eu.svjatoslav.aukio.e3d.gui;
+
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Hierarchical depth pyramid for whole-block occlusion culling
+ * (Hi-Z). Built from the just-painted frame's depth buffer; queried
+ * during the NEXT frame's transform to skip blocks that are fully
+ * hidden behind what was drawn last frame.
+ *
+ * <p>Semantics: each tile stores the MINIMUM w (= 1/z, i.e. the
+ * FARTHEST written depth) over its pixels. A block whose nearest
+ * possible point (max w over its AABB corners) is farther than a
+ * tile's farthest written depth is behind something at every written
+ * pixel of that tile — and tiles with any unwritten (sky) pixel hold
+ * -infinity and never occlude. That makes the test conservative: it
+ * may keep a hidden block, it never culls a visible one — under a
+ * static camera. (The first version max-pooled, storing the NEAREST
+ * depth per tile; that culled houses visible BETWEEN nearer tree
+ * trunks — per-pixel gaps inside a tile are invisible to a max.)
+ * Camera motion reuses the stale pyramid, which can cull a
+ * newly-visible block wrongly; the block's pixels then contain far
+ * background depth, so the next frame's pyramid no longer occludes
+ * it — wrong culls self-heal in one frame (sanctioned design
+ * concession), and the query margin absorbs small-motion
+ * parallax.</p>
+ *
+ * <p>Empty tiles (never depth-written) store -infinity and never
+ * occlude, so the first frame after startup (or after any pass that
+ * leaves the pyramid unbuilt) culls nothing.</p>
+ *
+ * <p>Knobs: {@code -Daukio.hiz=false} disables culling (build still
+ * happens — it is cheap — so flipping the flag needs no warm-up);
+ * {@code -Daukio.hiz.margin=0.02} sets the relative w-space safety
+ * margin against parallax between frames.</p>
+ */
+public final class HiZPyramid {
+
+    /** Level-0 tile edge in pixels; level i tiles cover TILE*2^i px. */
+    private static final int TILE = 8;
+
+    private static final boolean ENABLED = Boolean.parseBoolean(
+            System.getProperty("aukio.hiz", "true"));
+    private static final double MARGIN = Double.parseDouble(
+            System.getProperty("aukio.hiz.margin", "0.02"));
+
+    /** Blocks occlusion-tested this process (telemetry). */
+    public final AtomicLong blocksTested = new AtomicLong();
+    /** Blocks culled as fully occluded (telemetry). */
+    public final AtomicLong blocksCulled = new AtomicLong();
+
+    private float[] tiles = new float[0];
+    private int[] levelOff = new int[0];
+    private int[] levelW = new int[0];
+    private int[] levelH = new int[0];
+    private int levels;
+    private int bufW = -1, bufH = -1;
+
+    /** Rebuilds the pyramid from a freshly painted depth buffer. */
+    public synchronized void buildFrom(final float[] depth,
+                                       final int width, final int height) {
+        if (width != bufW || height != bufH) {
+            allocate(width, height);
+        }
+
+        // Level 0: min-pool depth into TILE x TILE tiles. Depth
+        // outside the painted area is -infinity (cleared), so a tile
+        // with any unpainted pixel never occludes.
+        final int w0 = levelW[0], h0 = levelH[0];
+        final int off0 = levelOff[0];
+        for (int ty = 0; ty < h0; ty++) {
+            final int yEnd = Math.min((ty + 1) * TILE, height);
+            for (int tx = 0; tx < w0; tx++) {
+                final int xEnd = Math.min((tx + 1) * TILE, width);
+                float m = Float.POSITIVE_INFINITY;
+                for (int y = ty * TILE; y < yEnd; y++) {
+                    final int row = y * width;
+                    for (int x = tx * TILE; x < xEnd; x++)
+                        if (depth[row + x] < m)
+                            m = depth[row + x];
+                }
+                tiles[off0 + ty * w0 + tx] = m;
+            }
+        }
+
+        // Higher levels: min of 2x2 children.
+        for (int l = 1; l < levels; l++) {
+            final int pw = levelW[l - 1], ph = levelH[l - 1];
+            final int poff = levelOff[l - 1];
+            final int cw = levelW[l], ch = levelH[l];
+            final int coff = levelOff[l];
+            for (int ty = 0; ty < ch; ty++)
+                for (int tx = 0; tx < cw; tx++) {
+                    float m = Float.POSITIVE_INFINITY;
+                    for (int dy = 0; dy < 2; dy++)
+                        for (int dx = 0; dx < 2; dx++) {
+                            final int sx = tx * 2 + dx, sy = ty * 2 + dy;
+                            if (sx < pw && sy < ph) {
+                                final float v = tiles[poff + sy * pw + sx];
+                                if (v < m)
+                                    m = v;
+                            }
+                        }
+                    tiles[coff + ty * cw + tx] = m;
+                }
+        }
+    }
+
+    /**
+     * Conservative whole-block occlusion test.
+     *
+     * @param x1..y2    screen-space AABB of the block (will be clamped
+     *                  to the buffer; a fully off-screen box returns
+     *                  false)
+     * @param nearestW  the block's nearest possible depth = MAX 1/z
+     *                  over its corners
+     * @return true when the block is certainly hidden behind last
+     *         frame's occluders (within the parallax margin)
+     */
+    public synchronized boolean occluded(final double x1, final double y1,
+                            final double x2, final double y2,
+                            final double nearestW) {
+        if (!ENABLED || levels == 0)
+            return false;
+
+        int bx1 = (int) Math.floor(x1), by1 = (int) Math.floor(y1);
+        int bx2 = (int) Math.ceil(x2), by2 = (int) Math.ceil(y2);
+        if (bx1 < 0) bx1 = 0;
+        if (by1 < 0) by1 = 0;
+        if (bx2 >= bufW) bx2 = bufW - 1;
+        if (by2 >= bufH) by2 = bufH - 1;
+        if (bx1 > bx2 || by1 > by2)
+            return false;
+
+        // Coarsest level where the box still covers <= 2 tiles per axis.
+        int level = 0;
+        while (level + 1 < levels) {
+            final int s = TILE << (level + 1);
+            final int tw = (bx2 / s) - (bx1 / s) + 1;
+            final int th = (by2 / s) - (by1 / s) + 1;
+            if (tw > 2 || th > 2)
+                break;
+            level++;
+        }
+
+        final int s = TILE << level;
+        final int tx1 = bx1 / s, ty1 = by1 / s;
+        final int tx2 = bx2 / s, ty2 = by2 / s;
+        final int w = levelW[level], off = levelOff[level];
+
+        float minStored = Float.POSITIVE_INFINITY;
+        for (int ty = ty1; ty <= ty2; ty++)
+            for (int tx = tx1; tx <= tx2; tx++) {
+                final float v = tiles[off + ty * w + tx];
+                if (v < minStored)
+                    minStored = v;
+            }
+
+        // Occluded only when the block's nearest point is clearly
+        // behind the farthest written depth in the range; the
+        // relative margin absorbs parallax between frames.
+        return nearestW < minStored * (1.0 - MARGIN);
+    }
+
+    private void allocate(final int width, final int height) {
+        bufW = width;
+        bufH = height;
+        int lw = (width + TILE - 1) / TILE;
+        int lh = (height + TILE - 1) / TILE;
+        int count = 0;
+        levels = 0;
+        while (true) {
+            levels++;
+            count += lw * lh;
+            if (lw == 1 && lh == 1)
+                break;
+            lw = Math.max(1, (lw + 1) / 2);
+            lh = Math.max(1, (lh + 1) / 2);
+        }
+        tiles = new float[count];
+        levelOff = new int[levels];
+        levelW = new int[levels];
+        levelH = new int[levels];
+        lw = (width + TILE - 1) / TILE;
+        lh = (height + TILE - 1) / TILE;
+        int off = 0;
+        for (int l = 0; l < levels; l++) {
+            levelOff[l] = off;
+            levelW[l] = lw;
+            levelH[l] = lh;
+            off += lw * lh;
+            lw = Math.max(1, (lw + 1) / 2);
+            lh = Math.max(1, (lh + 1) / 2);
+        }
+        Arrays.fill(tiles, Float.NEGATIVE_INFINITY);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/RenderingContext.java
new file mode 100644 (file)
index 0000000..22b90c2
--- /dev/null
@@ -0,0 +1,707 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.geometry.Frustum;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.awt.image.WritableRaster;
+import java.util.concurrent.ExecutorService;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator;
+import java.util.function.Consumer;
+
+/**
+ * Contains all state needed to render a single frame: the pixel buffer, graphics context,
+ * screen dimensions, and mouse event tracking.
+ *
+ * <p>A new {@code RenderingContext} is created whenever the view panel is resized.
+ * During rendering, shapes use this context to:</p>
+ * <ul>
+ *   <li>Access the raw pixel array ({@link #pixels}) for direct pixel manipulation</li>
+ *   <li>Access the {@link Graphics2D} context ({@link #graphics}) for Java2D drawing</li>
+ *   <li>Read screen dimensions ({@link #width}, {@link #height}) and the
+ *       {@link #centerCoordinate} for coordinate projection</li>
+ *   <li>Use the {@link #projectionScale} factor for perspective projection</li>
+ * </ul>
+ *
+ * <p>The context also manages mouse interaction detection: as shapes are painted
+ * back-to-front, each shape can report itself as the object under the mouse cursor.
+ * After painting completes, the topmost shape receives the mouse event.</p>
+ *
+ * @see ViewPanel the panel that creates and manages this context
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape#paint(RenderingContext)
+ */
+public class RenderingContext {
+
+    /**
+     * The {@link BufferedImage} pixel format used for the rendering buffer.
+     * TYPE_INT_RGB provides optimal performance for Java2D blitting.
+     */
+    public static final int bufferedImageType = BufferedImage.TYPE_INT_RGB;
+
+    /**
+     * Number of horizontal segments (bands) for parallel rendering.
+     * Bands are finer than the paint thread count: paint threads steal
+     * bands off a shared ticket until all bands are done, so a thread
+     * that finishes a cheap band immediately picks up more work.
+     * Derived from the render thread count via
+     * {@link ViewPanel#setNumRenderThreads(int)}.
+     *
+     * <p>Equals {@code tilesX * tilesY * viewportCount}: the tile grid
+     * covers one viewport, and in stereo mode a second grid covers the
+     * other eye (segment indices for the right eye start at
+     * {@code tilesX * tilesY}).</p>
+     */
+    public final int numRenderSegments;
+
+    /** Tile columns per viewport (1 = horizontal bands only). */
+    public final int tilesX;
+
+    /** Tile rows per viewport. */
+    public final int tilesY;
+
+    /** Number of side-by-side viewports (2 in stereo mode, else 1). */
+    public final int viewportCount;
+
+    /**
+     * Java2D graphics context for drawing text, anti-aliased shapes, and other
+     * high-level graphics operations onto the render buffer.
+     */
+    public final Graphics2D graphics;
+
+    /**
+     * Segment-specific Graphics2D contexts, each pre-clipped to a horizontal band.
+     * Used for thread-safe text and shape rendering without synchronization.
+     * Only initialized in the main RenderingContext; null in segment views.
+     */
+    private Graphics2D[] segmentGraphics;
+
+    /**
+     * Pixels of the rendering area.
+     * Each pixel is a single int in RGB format: {@code (r << 16) | (g << 8) | b}.
+     */
+    public final int[] pixels;
+
+    /**
+     * Per-pixel depth (biased 1/z, larger = nearer), always allocated —
+     * the painter path was deleted 2026-09-17 and the z-buffer is the
+     * only visibility mechanism. Shared with segment/pass copies like
+     * {@link #pixels}. Cleared per tile by the paint workers.
+     */
+    public float[] depth;
+
+    /**
+     * Active paint pass, set internally by
+     * {@code RenderAggregator.paintSorted}: 0 = not painting, 1 =
+     * opaque pass (opaque-class triangles only, depth test + write),
+     * 2 = alpha pass (alpha-carrying
+     * triangles only, depth test, no depth write). Shapes read it to
+     * decide whether they belong to the current pass.
+     */
+    public int depthPass;
+
+    /**
+     * Depth tolerance in world units for the z-buffer test, in the form
+     * {@code zw > stored - DEPTH_MARGIN_DZ * zw * zw} (tolerance behind
+     * stored, per-pixel at fragment depth). Default 0 = strict depth: any
+     * nonzero window exports per-triangle painter-sort errors into
+     * per-pixel occlusion errors (dirt whose triangles sort late beats
+     * road pavement that strictly wins at margin 0 — user bugreport
+     * 2026-09-16, road pose). Tunable via -Daukio.zbuffer.margin.
+     */
+    public static final double DEPTH_MARGIN_DZ =
+            Double.parseDouble(System.getProperty("aukio.zbuffer.margin", "0"));
+
+    /**
+     * Width of the rendering area in pixels.
+     */
+    public final int width;
+
+    /**
+     * Height of the rendering area in pixels.
+     */
+    public final int height;
+
+    /**
+     * Center of the screen in screen space (pixels).
+     * This is the point where (0,0) coordinate of the world space is rendered.
+     */
+    public final Point2D centerCoordinate;
+
+    /**
+     * Scale factor for perspective projection, derived from screen width.
+     * Used to convert normalized device coordinates to screen pixels.
+     * This is mutable to support stereo rendering where each eye has a different viewport width.
+     */
+    public double projectionScale;
+
+    /**
+     * Minimum Y coordinate (inclusive) to render. Used for multi-threaded rendering
+     * where each thread renders a horizontal segment.
+     */
+    public final int renderMinY;
+
+    /**
+     * Maximum Y coordinate (exclusive) to render. Used for multi-threaded rendering
+     * where each thread renders a horizontal segment.
+     */
+    public final int renderMaxY;
+
+    final BufferedImage bufferedImage;
+    /**
+     * Unique id of the current transform cycle, assigned by
+     * {@code ShapeCollection.transformShapes()} from a global counter.
+     * Unlike {@link #frameNumber} (per-context, can repeat across context
+     * instances), this never collides, so per-cycle memoization such as
+     * composite subtree weights can safely key on it.
+     */
+    public long transformCycleId;
+
+    /**
+     * Which projection buffer slot this context writes/reads: 0, 1 or 2.
+     * Cycles per render pass (per eye in stereo) when the
+     * triple-buffered pipeline is active, so the transform phase of a
+     * pass never overwrites the vertex state either of the two previous
+     * passes' paints may still be reading. Always 0 when the pipeline is
+     * off (tests, single-pass rendering).
+     */
+    public int vertexSlot = 0;
+
+    /**
+     * Near-plane distance in camera-space Z units. Polygons whose vertices
+     * straddle this plane are clipped against it (new intersection vertices
+     * are generated with interpolated UVs); polygons fully behind it are
+     * culled. Must be &gt; 0 so the perspective divide stays safe.
+     */
+    public double nearPlaneDistance = 1.0;
+
+    /**
+     * Number of frame that is currently being rendered.
+     * Every frame has its own number.
+     */
+    public int frameNumber = 0;
+
+    /**
+     * Projected-size cull threshold in screen pixels: shapes whose screen
+     * bounds span less than this in both axes are not queued for
+     * rendering. 0 (the default) disables the cull. Set globally with
+     * {@code -Daukio.cull.subpixel=<px>}.
+     */
+    public double subpixelCullingThreshold = Double.parseDouble(
+            System.getProperty("aukio.cull.subpixel", "0"));
+
+    /**
+     * Epoch of the subpixel-culling verdict cache, stamped per frame by
+     * {@code ShapeCollection.transformShapesBegin}: the epoch advances
+     * when the camera moves significantly (or after a bounded number of
+     * frames), which invalidates all cached skip verdicts and forces one
+     * re-evaluation pass. Meaningless when the cull is off.
+     */
+    public int subpixelCullingEpoch;
+
+    /**
+     * UI component that mouse is currently hovering over.
+     */
+    private MouseInteractionController objectPreviouslyUnderMouseCursor;
+    /**
+     * Mouse click event that needs to be processed.
+     * This event is processed only once per frame.
+     * If there are multiple objects under the mouse cursor, the top-most object will receive the event.
+     * If there are no objects under the mouse cursor, the event will be ignored.
+     * If there is no event, this field will be null.
+     * This field is set to null after the event is processed.
+     */
+    private MouseEvent mouseEvent;
+    /**
+     * UI component that mouse is currently hovering over.
+     */
+    private MouseInteractionController currentObjectUnderMouseCursor;
+    /**
+     * Texture coordinates of the mouse cursor on the hit shape (primary
+     * texture pixels), or NaN when the hit shape has no texture.
+     */
+    private double currentMouseTextureU = Double.NaN;
+    private double currentMouseTextureV = Double.NaN;
+    /**
+     * Developer tools for this rendering context.
+     * Controls diagnostic features like logging and visualization.
+     */
+    public DeveloperTools developerTools;
+
+    /**
+     * Debug log buffer for capturing diagnostic output.
+     * Shapes can log messages here that appear in the Developer Tools panel.
+     */
+    public DebugLogBuffer debugLogBuffer;
+
+    /**
+     * Global lighting manager for the scene.
+     * All shaded polygons use this to calculate lighting. Contains all light sources
+     * and ambient light settings for the world.
+     */
+    public LightingManager lightingManager;
+
+    /**
+     * Which eye is being rendered in stereo mode. NONE for normal single-view rendering.
+     */
+    public StereoEye stereoEye = StereoEye.NONE;
+
+    /**
+     * Width of the viewport for the current eye in stereo mode.
+     * Equals {@link #width} when not in stereo mode.
+     */
+    public int stereoViewportWidth;
+
+    /**
+     * X offset of the current eye's viewport within the full buffer.
+     * 0 for left eye, width/2 for right eye, 0 in normal mode.
+     */
+    public int stereoViewportOffsetX;
+
+    /**
+     * Minimum X coordinate (inclusive) for rendering.
+     * In stereo mode, this is {@link #stereoViewportOffsetX}.
+     * In normal mode, this is 0.
+     */
+    public int renderMinX;
+
+    /**
+     * Maximum X coordinate (exclusive) for rendering.
+     * In stereo mode, this is {@link #stereoViewportOffsetX} + {@link #stereoViewportWidth}.
+     * In normal mode, this is {@link #width}.
+     */
+    public int renderMaxX;
+
+    /**
+     * View frustum for frustum culling.
+     * Updated each frame from camera state and screen dimensions.
+     * Shapes can test their bounding boxes against this frustum to determine
+     * if they are potentially visible before expensive vertex transformations.
+     */
+    public Frustum frustum;
+
+    /**
+     * World-space position of the viewer for this pass, copied from the
+     * camera in {@link eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection#transformShapesBegin}.
+     * Used by BSP painter ordering (viewpoint for tree traversal).
+     * Fresh instance per context, including per-pass copies, so overlapping
+     * pipeline passes each see their own viewpoint.
+     */
+    public final eu.svjatoslav.aukio.e3d.geometry.Point3D viewerPosition =
+            new eu.svjatoslav.aukio.e3d.geometry.Point3D();
+
+    /**
+     * Statistics for frustum culling performance tracking.
+     * Updated each frame: total shapes counted at start, visible shapes
+     * incremented during rendering, culled composites tracked during transform.
+     */
+    public CullingStatistics cullingStatistics;
+
+    /**
+     * Hi-Z occlusion pyramid, rebuilt from the depth buffer after every
+     * painted frame (live {@code ViewPanel} path only — headless
+     * {@code Snapshot} renders leave it empty so golden renders never
+     * cull). Read during the next frame's transform by
+     * {@code TriangleMeshBlock} to skip fully occluded blocks. Shared
+     * with pass/segment copies like {@link #depth}.
+     */
+    public HiZPyramid occlusionPyramid;
+
+    /**
+     * Executor for the parallel transform phase. When non-null, composites
+     * with enough children split their render lists into chunks transformed
+     * concurrently. When null, the transform phase runs serially on the
+     * render thread.
+     */
+    public ExecutorService transformExecutor;
+
+    /**
+     * Per-frame coordinator for the non-blocking parallel transform fork.
+     * Set by {@code ShapeCollection.transformShapes()} for the duration of
+     * the root transform when {@link #transformExecutor} is available;
+     * composites at any nesting level submit chunk tasks to it. Null outside
+     * the transform phase and when transforming serially.
+     */
+    public ParallelTransformCoordinator transformCoordinator;
+
+    /**
+     * Present gate for this framebuffer: fires when the frame currently
+     * held in this buffer has been presented to the display (or dropped
+     * from the presentation mailbox). The render thread installs a fresh
+     * gate at the start of each frame that reuses the buffer, and the
+     * frame's paint continuation awaits the PREVIOUS gate before writing
+     * pixels — without it, painting frame F+3 would overwrite the buffer
+     * while the present thread is still blitting frame F from it.
+     */
+    public volatile java.util.concurrent.CountDownLatch presentGate = new java.util.concurrent.CountDownLatch(0);
+
+    /**
+     * Chunk tasks submitted during the last transform phase, across all
+     * nesting levels. Diagnostics: proves nested composites forked.
+     */
+    public int lastTransformTaskCount;
+
+    /**
+     * Creates a new rendering context for full-screen rendering.
+     *
+     * <p>Equivalent to {@code RenderingContext(width, height, 0, height, numRenderSegments)}.</p>
+     *
+     * @param width             the rendering area width in pixels
+     * @param height            the rendering area height in pixels
+     * @param numRenderSegments number of parallel render segments (threads)
+     */
+    public RenderingContext(final int width, final int height, final int numRenderSegments) {
+        this(width, height, 0, height, 1, numRenderSegments, 1);
+    }
+
+    /**
+     * Creates a new rendering context with a rectangular tile grid.
+     *
+     * <p>Equivalent to the band-only constructors when {@code tilesX == 1}.
+     * In stereo mode ({@code viewportCount == 2}) each viewport gets its own
+     * tile grid; segment indices for viewport v start at
+     * {@code v * tilesX * tilesY}.</p>
+     *
+     * @param width         the rendering area width in pixels
+     * @param height        the rendering area height in pixels
+     * @param tilesX        tile columns per viewport (1 = bands only)
+     * @param tilesY        tile rows per viewport
+     * @param viewportCount number of side-by-side viewports (2 = stereo)
+     */
+    public RenderingContext(final int width, final int height,
+                            final int tilesX, final int tilesY,
+                            final int viewportCount) {
+        this(width, height, 0, height, tilesX, tilesY, viewportCount);
+    }
+
+    private RenderingContext(final int width, final int height,
+                             final int renderMinY, final int renderMaxY,
+                             final int tilesX, final int tilesY,
+                             final int viewportCount) {
+        this.width = width;
+        this.height = height;
+        this.renderMinY = renderMinY;
+        this.renderMaxY = renderMaxY;
+        this.tilesX = tilesX;
+        this.tilesY = tilesY;
+        this.viewportCount = viewportCount;
+        this.numRenderSegments = tilesX * tilesY * viewportCount;
+        this.centerCoordinate = new Point2D(width / 2d, height / 2d);
+        this.projectionScale = width / 3d;
+        this.stereoViewportWidth = width;
+        this.stereoViewportOffsetX = 0;
+        this.renderMinX = 0;
+        this.renderMaxX = width;
+
+        // Eagerly allocated so the developer-tools panel always finds it:
+        // transformPass() hands the pipeline a per-pass COPY of this context,
+        // and the copy constructor shares this reference. Lazy creation in
+        // ShapeCollection.transformShapesBegin() would only ever populate the
+        // throwaway pass copy, leaving this frame context null forever
+        // (the culling display then showed "-" permanently).
+        this.cullingStatistics = new CullingStatistics();
+        this.occlusionPyramid = new HiZPyramid();
+
+        bufferedImage = new BufferedImage(width, height, bufferedImageType);
+
+        final WritableRaster raster = bufferedImage.getRaster();
+        final DataBufferInt dbi = (DataBufferInt) raster.getDataBuffer();
+        pixels = dbi.getData();
+
+        // Z-buffer mode (-Daukio.zbuffer=true): one w-depth (biased 1/z)
+        // value per pixel, cleared per tile in the paint workers. Depth
+        // turns the queue order into a performance heuristic only;
+        // correctness comes from the per-pixel test. (The queue itself
+        // stays painter back-to-front — Z descending, see
+        // RenderAggregator.) Null in classic painter mode.
+        depth = new float[width * height];
+
+        graphics = (Graphics2D) bufferedImage.getGraphics();
+        graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+        segmentGraphics = createSegmentGraphics();
+    }
+
+    /**
+     * Protected constructor for creating segment views.
+     * Shares the pixel buffer and graphics context with the parent.
+     *
+     * @param parent     the parent rendering context
+     * @param renderMinY minimum Y coordinate (inclusive) for this segment
+     * @param renderMaxY maximum Y coordinate (exclusive) for this segment
+     */
+    protected RenderingContext(final RenderingContext parent,
+                               final int renderMinY, final int renderMaxY) {
+        this.width = parent.width;
+        this.height = parent.height;
+        this.renderMinY = renderMinY;
+        this.renderMaxY = renderMaxY;
+        this.tilesX = parent.tilesX;
+        this.tilesY = parent.tilesY;
+        this.viewportCount = parent.viewportCount;
+        this.numRenderSegments = parent.numRenderSegments;
+        this.centerCoordinate = parent.centerCoordinate;
+        this.projectionScale = parent.projectionScale;
+        this.stereoViewportWidth = parent.stereoViewportWidth;
+        this.stereoViewportOffsetX = parent.stereoViewportOffsetX;
+        this.stereoEye = parent.stereoEye;
+        this.renderMinX = parent.renderMinX;
+        this.renderMaxX = parent.renderMaxX;
+        this.bufferedImage = parent.bufferedImage;
+        this.pixels = parent.pixels;
+        this.depth = parent.depth;
+        this.graphics = parent.graphics;
+        this.vertexSlot = parent.vertexSlot;
+        this.nearPlaneDistance = parent.nearPlaneDistance;
+        this.developerTools = parent.developerTools;
+        this.debugLogBuffer = parent.debugLogBuffer;
+        this.lightingManager = parent.lightingManager;
+        this.occlusionPyramid = parent.occlusionPyramid;
+        this.segmentGraphics = null;
+    }
+
+    /**
+     * Creates an independent pass context for one pipeline pass (one eye
+     * in stereo): shares the frame's pixel buffer, graphics and services,
+     * but owns the per-pass projection fields (center, scale, stereo
+     * viewport, slot, frame/cycle stamps). The next pass's setup writes
+     * to its own copy, so it cannot disturb this pass's in-flight
+     * transform chunks or its asynchronous sort/bin/paint continuation.
+     *
+     * @param parent the frame rendering context to copy from
+     */
+    public RenderingContext(final RenderingContext parent) {
+        this.width = parent.width;
+        this.height = parent.height;
+        this.renderMinY = parent.renderMinY;
+        this.renderMaxY = parent.renderMaxY;
+        this.tilesX = parent.tilesX;
+        this.tilesY = parent.tilesY;
+        this.viewportCount = parent.viewportCount;
+        this.numRenderSegments = parent.numRenderSegments;
+        this.centerCoordinate = new Point2D(parent.centerCoordinate.x, parent.centerCoordinate.y);
+        this.projectionScale = parent.projectionScale;
+        this.stereoViewportWidth = parent.stereoViewportWidth;
+        this.stereoViewportOffsetX = parent.stereoViewportOffsetX;
+        this.stereoEye = parent.stereoEye;
+        this.renderMinX = parent.renderMinX;
+        this.renderMaxX = parent.renderMaxX;
+        this.bufferedImage = parent.bufferedImage;
+        this.pixels = parent.pixels;
+        this.depth = parent.depth;
+        this.graphics = parent.graphics;
+        this.vertexSlot = parent.vertexSlot;
+        this.nearPlaneDistance = parent.nearPlaneDistance;
+        this.frameNumber = parent.frameNumber;
+        this.transformCycleId = parent.transformCycleId;
+        this.transformExecutor = parent.transformExecutor;
+        this.developerTools = parent.developerTools;
+        this.debugLogBuffer = parent.debugLogBuffer;
+        this.lightingManager = parent.lightingManager;
+        this.cullingStatistics = parent.cullingStatistics;
+        this.occlusionPyramid = parent.occlusionPyramid;
+        this.subpixelCullingThreshold = parent.subpixelCullingThreshold;
+        this.subpixelCullingEpoch = parent.subpixelCullingEpoch;
+        this.setMouseEvent(parent.getMouseEvent());
+        // Share the pre-clipped per-tile graphics: glyph rendering
+        // (user-facing text) draws through them by segment index. Null
+        // here made every glyph paint die with an NPE mid-tile (broken
+        // tiles whenever text faced the reader).
+        this.segmentGraphics = parent.segmentGraphics;
+        // frustum stays null: created fresh per pass in transformShapesBegin
+    }
+
+    /**
+     * Resets per-frame state in preparation for rendering a new frame.
+     * Increments the frame number and clears the mouse event state.
+     */
+    public void prepareForNewFrameRendering() {
+        frameNumber++;
+        mouseEvent = null;
+        currentObjectUnderMouseCursor = null;
+    }
+
+    /**
+     * Creates Graphics2D contexts for each render segment, pre-clipped to
+     * its tile rectangle. Segment index layout: viewport v, tile row ty,
+     * tile column tx -> v * tilesX * tilesY + ty * tilesX + tx.
+     *
+     * @return array of Graphics2D objects, one per segment
+     */
+    private Graphics2D[] createSegmentGraphics() {
+        final Graphics2D[] contexts = new Graphics2D[numRenderSegments];
+        final int viewportWidth = width / viewportCount;
+        final int tileW = viewportWidth / tilesX;
+        final int tileH = height / tilesY;
+
+        for (int v = 0; v < viewportCount; v++) {
+            final int viewportX = v * viewportWidth;
+            for (int ty = 0; ty < tilesY; ty++) {
+                final int minY = ty * tileH;
+                final int maxY = (ty == tilesY - 1) ? height : (ty + 1) * tileH;
+                for (int tx = 0; tx < tilesX; tx++) {
+                    final int minX = viewportX + tx * tileW;
+                    final int maxX = (tx == tilesX - 1)
+                            ? viewportX + viewportWidth : minX + tileW;
+
+                    final Graphics2D g = bufferedImage.createGraphics();
+                    g.setClip(minX, minY, maxX - minX, maxY - minY);
+                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+                    g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+                    contexts[v * tilesX * tilesY + ty * tilesX + tx] = g;
+                }
+            }
+        }
+
+        return contexts;
+    }
+
+    /**
+     * Returns the backing image whose pixel buffer the rasterizer paints into.
+     *
+     * <p>Exposed for headless rendering: after a transform/sort/paint pass the
+     * image holds the finished frame and can be saved or compared directly.</p>
+     *
+     * @return the backing buffered image
+     */
+    public BufferedImage getImage() {
+        return bufferedImage;
+    }
+
+    /**
+     * Returns the Graphics2D context for a specific render segment.
+     * Each segment's Graphics2D is pre-clipped to its Y bounds.
+     *
+     * @param segmentIndex the segment index (0 to numRenderSegments-1)
+     * @return the Graphics2D for that segment
+     * @throws NullPointerException if called on a segment view (not the main context)
+     */
+    public Graphics2D getSegmentGraphics(final int segmentIndex) {
+        return segmentGraphics[segmentIndex];
+    }
+
+    /**
+     * Disposes all Graphics2D resources associated with this context.
+     * Should be called when the context is no longer needed (e.g., on resize).
+     */
+    public void dispose() {
+        if (segmentGraphics != null) {
+            for (final Graphics2D g : segmentGraphics) {
+                if (g != null) {
+                    g.dispose();
+                }
+            }
+        }
+        if (graphics != null) {
+            graphics.dispose();
+        }
+    }
+
+    /**
+     * Executes a graphics operation in a thread-safe manner.
+     * This must be used for all Graphics2D operations (text, lines, etc.)
+     * during multi-threaded rendering.
+     *
+     * @param operation the graphics operation to execute
+     */
+    public void executeWithGraphics(final Consumer<Graphics2D> operation) {
+        synchronized (graphics) {
+            operation.accept(graphics);
+        }
+    }
+
+    /**
+     * Returns the pending mouse event for this frame, or {@code null} if none.
+     *
+     * @return the mouse event to process, or {@code null}
+     */
+    public MouseEvent getMouseEvent() {
+        return mouseEvent;
+    }
+
+    /**
+     * Sets the mouse event to be processed during this frame's rendering.
+     *
+     * @param mouseEvent the mouse event with position and button information
+     */
+    public void setMouseEvent(MouseEvent mouseEvent) {
+        this.mouseEvent = mouseEvent;
+    }
+
+    /**
+     * Called when given object was detected under mouse cursor, while processing {@link #mouseEvent}.
+     * Because objects are rendered back to front. The last method caller will set the top-most object, if
+     * there are multiple objects under mouse cursor.
+     *
+     * @param currentObjectUnderMouseCursor the object that is currently under the mouse cursor
+     */
+    public synchronized void setCurrentObjectUnderMouseCursor(MouseInteractionController currentObjectUnderMouseCursor) {
+        setCurrentObjectUnderMouseCursor(currentObjectUnderMouseCursor,
+                Double.NaN, Double.NaN);
+    }
+
+    /**
+     * Called when given object was detected under mouse cursor, with the
+     * texture coordinates of the hit point (for textured shapes).
+     *
+     * @param currentObjectUnderMouseCursor the object under the mouse cursor
+     * @param textureU texture-space X of the hit point in primary-texture pixels
+     * @param textureV texture-space Y of the hit point in primary-texture pixels
+     */
+    public synchronized void setCurrentObjectUnderMouseCursor(
+            final MouseInteractionController currentObjectUnderMouseCursor,
+            final double textureU, final double textureV) {
+        this.currentObjectUnderMouseCursor = currentObjectUnderMouseCursor;
+        this.currentMouseTextureU = textureU;
+        this.currentMouseTextureV = textureV;
+    }
+
+    /**
+     * Returns the current object under the mouse cursor.
+     * Used by segment rendering to collect mouse results.
+     *
+     * @return the current object under mouse cursor, or null
+     */
+    public synchronized MouseInteractionController getCurrentObjectUnderMouseCursor() {
+        return currentObjectUnderMouseCursor;
+    }
+
+    /**
+     * Handles mouse events for components and returns whether a view repaint is needed.
+     *
+     * @return {@code true} if view update is needed as a consequence of this mouse event
+     */
+    public boolean handlePossibleComponentMouseEvent() {
+        if (mouseEvent == null) return false;
+
+        boolean viewRepaintNeeded = false;
+
+        if (objectPreviouslyUnderMouseCursor != currentObjectUnderMouseCursor) {
+            // Mouse cursor has just entered or left component.
+            viewRepaintNeeded = objectPreviouslyUnderMouseCursor != null && objectPreviouslyUnderMouseCursor.mouseExited();
+            viewRepaintNeeded |= currentObjectUnderMouseCursor != null && currentObjectUnderMouseCursor.mouseEntered();
+            objectPreviouslyUnderMouseCursor = currentObjectUnderMouseCursor;
+        }
+
+        if (mouseEvent.button != 0 && currentObjectUnderMouseCursor != null) {
+            // Mouse button was clicked on some component.
+            viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseClicked(
+                    mouseEvent.button, currentMouseTextureU, currentMouseTextureV);
+        } else if (currentObjectUnderMouseCursor != null)
+            // hover: let the component track the pointer position
+            viewRepaintNeeded |= currentObjectUnderMouseCursor.mouseHover(
+                    currentMouseTextureU, currentMouseTextureV);
+
+        return viewRepaintNeeded;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/SegmentRenderingContext.java
new file mode 100644 (file)
index 0000000..a7cc17b
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+
+import java.awt.*;
+import java.util.function.Consumer;
+
+/**
+ * A view of a RenderingContext for rendering a horizontal screen segment.
+ *
+ * <p>This class wraps a parent RenderingContext and provides its own Y-bounds
+ * for multi-threaded rendering. All operations delegate to the parent context,
+ * but with segment-specific Y bounds for pixel operations.</p>
+ *
+ * <p>Mouse tracking is local to each segment and must be combined after all
+ * segments complete rendering.</p>
+ *
+ * @see RenderingContext
+ */
+public class SegmentRenderingContext extends RenderingContext {
+
+    private final RenderingContext parent;
+    private final int segmentIndex;
+    private MouseInteractionController segmentMouseHit;
+    private double segmentMouseHitU = Double.NaN;
+    private double segmentMouseHitV = Double.NaN;
+
+    /**
+     * Creates a segment view of a parent rendering context.
+     *
+     * @param parent       the parent rendering context to delegate to
+     * @param renderMinY   minimum Y coordinate (inclusive) for this segment
+     * @param renderMaxY   maximum Y coordinate (exclusive) for this segment
+     * @param segmentIndex the index of this segment (0 to numRenderSegments-1)
+     */
+    public SegmentRenderingContext(final RenderingContext parent,
+                                    final int renderMinY, final int renderMaxY,
+                                    final int segmentIndex) {
+        super(parent, renderMinY, renderMaxY);
+        this.parent = parent;
+        this.segmentIndex = segmentIndex;
+    }
+
+    @Override
+    public void executeWithGraphics(final Consumer<Graphics2D> operation) {
+        operation.accept(parent.getSegmentGraphics(segmentIndex));
+    }
+
+    @Override
+    public MouseEvent getMouseEvent() {
+        return parent.getMouseEvent();
+    }
+
+    @Override
+    public void setMouseEvent(final MouseEvent mouseEvent) {
+        parent.setMouseEvent(mouseEvent);
+    }
+
+    @Override
+    public synchronized void setCurrentObjectUnderMouseCursor(final MouseInteractionController controller) {
+        setCurrentObjectUnderMouseCursor(controller, Double.NaN, Double.NaN);
+    }
+
+    @Override
+    public synchronized void setCurrentObjectUnderMouseCursor(
+            final MouseInteractionController controller,
+            final double textureU, final double textureV) {
+        this.segmentMouseHit = controller;
+        this.segmentMouseHitU = textureU;
+        this.segmentMouseHitV = textureV;
+    }
+
+    /**
+     * Returns the mouse hit detected in this segment.
+     *
+     * @return the MouseInteractionController that was under the mouse in this segment, or null
+     */
+    public MouseInteractionController getSegmentMouseHit() {
+        return segmentMouseHit;
+    }
+
+    /**
+     * Texture-space X of the hit point (primary texture pixels), NaN if none.
+     */
+    public double getSegmentMouseHitU() {
+        return segmentMouseHitU;
+    }
+
+    /**
+     * Texture-space Y of the hit point (primary texture pixels), NaN if none.
+     */
+    public double getSegmentMouseHitV() {
+        return segmentMouseHitV;
+    }
+
+    @Override
+    public synchronized MouseInteractionController getCurrentObjectUnderMouseCursor() {
+        return segmentMouseHit;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/StereoEye.java
new file mode 100644 (file)
index 0000000..aa9c99f
--- /dev/null
@@ -0,0 +1,19 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+/**
+ * Identifies which eye is being rendered in stereoscopic mode.
+ *
+ * @see RenderingContext#stereoEye
+ */
+public enum StereoEye {
+    /** Normal single-view rendering (no stereo). */
+    NONE,
+    /** Left eye view in side-by-side stereo mode. */
+    LEFT,
+    /** Right eye view in side-by-side stereo mode. */
+    RIGHT
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/TextPointer.java
new file mode 100755 (executable)
index 0000000..fc057a5
--- /dev/null
@@ -0,0 +1,123 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import static java.lang.Integer.compare;
+
+/**
+ * A pointer to a character in a text using row and column.
+ * <p>
+ * It can be used to represent a cursor position in a text.
+ * Also, it can be used to represent beginning and end of a selection.
+ */
+public class TextPointer implements Comparable<TextPointer> {
+
+    /**
+     * The row of the character. Starts from 0.
+     */
+    public int row;
+
+    /**
+     * The column of the character. Starts from 0.
+     */
+    public int column;
+
+    /**
+     * Creates a text pointer at position (0, 0).
+     */
+    public TextPointer() {
+        this(0, 0);
+    }
+
+    /**
+     * Creates a text pointer at the specified row and column.
+     *
+     * @param row    the row index (0-based)
+     * @param column the column index (0-based)
+     */
+    public TextPointer(final int row, final int column) {
+        this.row = row;
+        this.column = column;
+    }
+
+    /**
+     * Creates a text pointer by copying another text pointer.
+     *
+     * @param parent the text pointer to copy
+     */
+    public TextPointer(final TextPointer parent) {
+        this(parent.row, parent.column);
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (o == null) return false;
+
+        return o instanceof TextPointer && compareTo((TextPointer) o) == 0;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = row;
+        result = 31 * result + column;
+        return result;
+    }
+
+    /**
+     * Compares this pointer to another pointer.
+     *
+     * @param textPointer The pointer to compare to.
+     * @return <ul>
+     *     <li>-1 if this pointer is smaller than the argument pointer.</li>
+     *     <li>0 if they are equal.</li>
+     *     <li>1 if this pointer is bigger than the argument pointer.</li>
+     *     </ul>
+     */
+    @Override
+    public int compareTo(final TextPointer textPointer) {
+
+        if (row < textPointer.row)
+            return -1;
+        if (row > textPointer.row)
+            return 1;
+
+        return compare(column, textPointer.column);
+    }
+
+    /**
+     * Checks if this pointer is between the argument pointers.
+     * <p>
+     * This pointer is considered to be between the pointers if it is bigger or equal to the start pointer
+     * and smaller than the end pointer.
+     *
+     * @param start The start pointer.
+     * @param end   The end pointer.
+     * @return True if this pointer is between the specified pointers.
+     */
+    public boolean isBetween(final TextPointer start, final TextPointer end) {
+
+        if (start == null)
+            return false;
+
+        if (end == null)
+            return false;
+
+        // Make sure that start is smaller than end.
+        TextPointer smaller;
+        TextPointer bigger;
+
+        if (end.compareTo(start) >= 0) {
+            smaller = start;
+            bigger = end;
+        } else {
+            smaller = end;
+            bigger = start;
+        }
+
+        // Check if this pointer is between the specified pointers.
+        return (compareTo(smaller) >= 0) && (bigger.compareTo(this) > 0);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadActivityRecorder.java
new file mode 100644 (file)
index 0000000..eff1e39
--- /dev/null
@@ -0,0 +1,212 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Low-overhead recorder of per-thread work intervals for the thread
+ * timeline display in the Developer Tools window.
+ *
+ * <p>Task submission sites (transform chunks, paint tiles, tile binning)
+ * and render-thread phases (orchestration, waiting, blit) record their
+ * [start, end) intervals here when enabled. The timeline component paints
+ * each thread as a row, time on the X axis, the interval kind as color —
+ * the software-renderer equivalent of a GPU frame-profiler occupancy
+ * view. Idle time is simply the absence of intervals (black).</p>
+ *
+ * <p>Overhead when disabled: one volatile read per task. When enabled:
+ * two {@code System.nanoTime()} calls, one atomic increment and four
+ * array stores per task (~100 ns), negligible at hundreds of tasks per
+ * frame.</p>
+ *
+ * <p>Frame parity distinguishes "current frame" work from "next frame"
+ * work in the colors: transform/paint/binning kinds come in an even and
+ * an odd variant, selected by the parity that was current when the task
+ * was SUBMITTED (captured at task creation, so overlapping frames keep
+ * their own color).</p>
+ */
+public final class ThreadActivityRecorder {
+
+    /** Kind base: vertex transform chunk (add frame parity 0..2). */
+    public static final int KIND_TRANSFORM = 0;
+    /** Kind base: paint tile task (add frame parity 0..2). */
+    public static final int KIND_PAINT = 3;
+    /** Kind base: tile binning task (add frame parity 0..2). */
+    public static final int KIND_BIN = 6;
+    /** Kind: render thread orchestration (tree walk, submission, merges). */
+    public static final int KIND_RENDER = 9;
+    /** Kind: render thread blocked waiting for worker tasks. */
+    public static final int KIND_AWAIT = 10;
+    /** Kind: render thread blitting the finished frame to screen. */
+    public static final int KIND_BLIT = 11;
+    /** Kind: a pass's asynchronous continuation (drain, sort, bin, paint submission). */
+    public static final int KIND_PREP = 12;
+    /** Kind: continuation sub-phase: draining and merging transform chunks. */
+    public static final int KIND_DRAIN = 13;
+    /** Kind: continuation sub-phase: depth sort. */
+    public static final int KIND_SORT = 14;
+
+    /** Number of distinct kinds (three parities each for transform/paint/bin, plus 9..14). */
+    public static final int KIND_COUNT = 15;
+
+    private static final int CAPACITY = 1 << 18;
+    private static final int MASK = CAPACITY - 1;
+
+    private static final long[] starts = new long[CAPACITY];
+    private static final long[] ends = new long[CAPACITY];
+    private static final byte[] kinds = new byte[CAPACITY];
+    private static final byte[] rows = new byte[CAPACITY];
+    private static final AtomicInteger cursor = new AtomicInteger();
+
+    private static volatile boolean enabled = false;
+    private static volatile int frameParity = 0;
+
+    private static final ConcurrentHashMap<String, Integer> rowByThreadName = new ConcurrentHashMap<>();
+    private static final CopyOnWriteArrayList<String> rowNames = new CopyOnWriteArrayList<>();
+    private static final AtomicInteger nextRow = new AtomicInteger();
+
+    private ThreadActivityRecorder() {
+    }
+
+    /**
+     * @return true when recording is active (checked by task submission sites)
+     */
+    public static boolean isEnabled() {
+        return enabled;
+    }
+
+    /**
+     * Enables or disables recording. Enabling starts with a clean buffer
+     * and fresh thread-row assignment.
+     *
+     * @param value true to start recording
+     */
+    public static void setEnabled(final boolean value) {
+        if (value && !enabled) {
+            clear();
+        }
+        enabled = value;
+    }
+
+    /**
+     * Drops all recorded intervals and thread-row assignments.
+     */
+    public static void clear() {
+        cursor.set(0);
+        rowByThreadName.clear();
+        rowNames.clear();
+        nextRow.set(0);
+        // starts[]==0 marks an empty slot for the timeline sweep
+        java.util.Arrays.fill(starts, 0L);
+    }
+
+    /**
+     * Sets the parity (0/1) of the frame currently being prepared.
+     * Called by the render thread at the start of each frame; task
+     * submission sites capture it into their tasks so overlapping frames
+     * keep distinct colors.
+     *
+     * @param parity frame parity, 0 or 1
+     */
+    public static void setFrameParity(final int parity) {
+        frameParity = parity;
+    }
+
+    /**
+     * @return parity of the frame currently being prepared
+     */
+    public static int frameParity() {
+        return frameParity;
+    }
+
+    /**
+     * Records one work interval on the calling thread.
+     *
+     * @param kind   interval kind (one of the KIND_* bases, plus parity
+     *               for transform/paint/binning)
+     * @param t0     interval start, from {@link System#nanoTime()}
+     * @param t1     interval end, from {@link System#nanoTime()}
+     */
+    public static void record(final int kind, final long t0, final long t1) {
+        // Rows are keyed by thread NAME, not Thread object: after a
+        // stop()/start() cycle the executor is recreated with fresh
+        // threads under the same names, and they must reuse the same
+        // rows — otherwise the timeline fills with dead threads' rows
+        // and pushes the live workers below the visible area.
+        final String threadName = Thread.currentThread().getName();
+        Integer row = rowByThreadName.get(threadName);
+        if (row == null) {
+            row = rowByThreadName.computeIfAbsent(threadName, t -> {
+                final int r = nextRow.getAndIncrement();
+                rowNames.add(t);
+                return r;
+            });
+        }
+        if (row > 127) {
+            return;
+        }
+        final int i = cursor.getAndIncrement() & MASK;
+        starts[i] = t0;
+        ends[i] = t1;
+        kinds[i] = (byte) kind;
+        rows[i] = (byte) (int) row;
+    }
+
+    // ---- Snapshot access for the timeline component ----
+
+    /** @return total number of intervals recorded since the last clear */
+    public static int cursor() {
+        return cursor.get();
+    }
+
+    /** @return ring buffer capacity */
+    public static int capacity() {
+        return CAPACITY;
+    }
+
+    /** @return interval start array (index space of the ring buffer) */
+    public static long[] starts() {
+        return starts;
+    }
+
+    /** @return interval end array (index space of the ring buffer) */
+    public static long[] ends() {
+        return ends;
+    }
+
+    /** @return interval kind array (index space of the ring buffer) */
+    public static byte[] kinds() {
+        return kinds;
+    }
+
+    /** @return interval thread-row array (index space of the ring buffer) */
+    public static byte[] rows() {
+        return rows;
+    }
+
+    /** @return number of distinct thread rows seen so far */
+    public static int rowCount() {
+        return nextRow.get();
+    }
+
+    /**
+     * @param row thread row index
+     * @return display name for the row ("render" for the render thread,
+     *         otherwise the thread name with the e3d- prefix stripped)
+     */
+    public static String rowName(final int row) {
+        if (row >= rowNames.size()) {
+            return "?";
+        }
+        final String name = rowNames.get(row);
+        if ("e3d-render".equals(name)) {
+            return "render";
+        }
+        return name.startsWith("e3d-") ? name.substring(4) : name;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ThreadTimelineComponent.java
new file mode 100644 (file)
index 0000000..aafa7ae
--- /dev/null
@@ -0,0 +1,322 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.AdjustmentEvent;
+import java.awt.event.AdjustmentListener;
+import java.awt.event.MouseWheelEvent;
+import java.awt.event.MouseWheelListener;
+
+/**
+ * Per-thread activity timeline for the Developer Tools window — the
+ * software-renderer equivalent of a GPU frame profiler's occupancy view.
+ *
+ * <p>One row per worker thread, plus the render and present threads
+ * pinned to the top. Worker rows are labeled CPU 01..N in a stable
+ * order. Time on the X axis, interval kind as color. Black gaps are
+ * idle time — the spots
+ * where a core had no work, which is what this view exists to find. In a
+ * perfectly pipelined renderer every worker row is solid: when transform
+ * of frame N+1 (yellow-green) starts appearing inside paint of frame N
+ * (blue), the pipeline is overlapping as intended.</p>
+ *
+ * <p>While recording, the view shows a live-scrolling window of the last
+ * ~100 ms. After Record is toggled off the capture freezes and can be
+ * scrolled back through the whole retained history (the ring buffer
+ * keeps roughly the last 10+ seconds at typical task rates) with the
+ * scrollbar or the mouse wheel.</p>
+ *
+ * <p>Reads the {@link ThreadActivityRecorder} ring buffer directly on
+ * repaint; recording itself is toggled from the parent panel.</p>
+ */
+public class ThreadTimelineComponent extends JComponent {
+
+    /** Time window shown, in nanoseconds. Adjustable with ctrl+wheel. */
+    private long windowNanos = 100_000_000L;
+
+    /** Minimum and maximum zoom window, nanoseconds. */
+    private static final long MIN_WINDOW_NANOS = 5_000_000L;
+    private static final long MAX_WINDOW_NANOS = 2_000_000_000L;
+
+    private static final int ROW_HEIGHT = 11;
+    private static final int LABEL_WIDTH = 64;
+    private static final int LEGEND_HEIGHT = 16;
+    /** Rows the component reserves height for, so all workers + render fit. */
+    private static final int RESERVED_ROWS = 26;
+
+    /** Colors per recorded kind: three frame parities each for transform/paint/bin, then 9..11. */
+    private static final Color[] KIND_COLORS = {
+            new Color(0x2E, 0xCC, 0x40),  // transform, frame%3=0 — green
+            new Color(0xB8, 0xD9, 0x00),  // transform, frame%3=1 — yellow-green
+            new Color(0x6B, 0x8E, 0x23),  // transform, frame%3=2 — olive
+            new Color(0x00, 0x74, 0xD9),  // paint, frame%3=0     — blue
+            new Color(0xF0, 0x12, 0xBE),  // paint, frame%3=1     — magenta
+            new Color(0xB1, 0x0D, 0xC9),  // paint, frame%3=2     — purple
+            new Color(0x39, 0xCC, 0xCC),  // binning, frame%3=0   — cyan
+            new Color(0x00, 0x8B, 0x8B),  // binning, frame%3=1   — dark cyan
+            new Color(0x00, 0x70, 0x70),  // binning, frame%3=2   — teal
+            new Color(0xA0, 0xA0, 0xA0),  // render-thread serial — gray
+            new Color(0x8B, 0x00, 0x00),  // render-thread waiting— dark red
+            new Color(0xFF, 0xFF, 0xFF),  // blit                 — white
+            new Color(0xFF, 0x85, 0x1B),  // continuation (drain/sort/bin) — orange
+            new Color(0x8B, 0x45, 0x13),  // drain+merge                — brown
+            new Color(0xFF, 0xD7, 0x00),  // depth sort                 — gold
+    };
+
+    private static final String[] LEGEND = {
+            "transform f0", "transform f1", "transform f2",
+            "paint f0", "paint f1", "paint f2",
+            "bin f0", "bin f1", "bin f2",
+            "render serial", "blocked", "blit", "sort+bin", "drain", "sort",
+    };
+
+    /**
+     * Scrollbar for navigating a frozen capture, in milliseconds scrolled
+     * back from the latest recorded interval. Owned here so the component
+     * can keep the model in sync with the capture length; the parent
+     * panel adds it below the timeline.
+     */
+    public final JScrollBar scrollBar = new JScrollBar(JScrollBar.HORIZONTAL, 0, 100, 0, 100);
+
+    /**
+     * How far back from the latest recorded interval the window ends,
+     * nanoseconds. Always 0 (live edge) while recording; user-adjustable
+     * on a frozen capture.
+     */
+    private long scrollBackNanos = 0;
+
+    /** True while the scrollbar model is being updated programmatically. */
+    private boolean updatingScrollBar = false;
+
+    public ThreadTimelineComponent() {
+        setBackground(Color.BLACK);
+
+        scrollBar.addAdjustmentListener(new AdjustmentListener() {
+            @Override
+            public void adjustmentValueChanged(final AdjustmentEvent e) {
+                if (!updatingScrollBar) {
+                    scrollBackNanos = scrollBar.getValue() * 1_000_000L;
+                    repaint();
+                }
+            }
+        });
+
+        addMouseWheelListener(new MouseWheelListener() {
+            @Override
+            public void mouseWheelMoved(final MouseWheelEvent e) {
+                if (e.isControlDown()) {
+                    // Zoom around the current window, x1.25 per notch
+                    for (int i = 0; i < Math.abs(e.getWheelRotation()); i++) {
+                        windowNanos = e.getWheelRotation() < 0
+                                ? Math.max(MIN_WINDOW_NANOS, windowNanos * 4 / 5)
+                                : Math.min(MAX_WINDOW_NANOS, windowNanos * 5 / 4);
+                    }
+                } else {
+                    if (ThreadActivityRecorder.isEnabled()) {
+                        return; // live mode: nothing to scroll
+                    }
+                    scrollBackNanos = Math.max(0, scrollBackNanos
+                            + e.getWheelRotation() * Math.max(1_000_000L, windowNanos / 10));
+                }
+                repaint();
+            }
+        });
+    }
+
+    @Override
+    public Dimension getPreferredSize() {
+        // Reserve full height up front: worker rows appear lazily as the
+        // pool starts, after the window has already been packed
+        return new Dimension(560, LEGEND_HEIGHT + RESERVED_ROWS * ROW_HEIGHT + 4);
+    }
+
+    @Override
+    protected void paintComponent(final Graphics graphics) {
+        super.paintComponent(graphics);
+        final Graphics2D g = (Graphics2D) graphics;
+        final int width = getWidth();
+        final int trackWidth = width - LABEL_WIDTH;
+
+        g.setColor(Color.BLACK);
+        g.fillRect(0, 0, width, getHeight());
+
+        // Legend
+        g.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 9));
+        int legendX = 2;
+        for (int kind = 0; kind < LEGEND.length; kind++) {
+            g.setColor(KIND_COLORS[kind]);
+            g.fillRect(legendX, 3, 8, 8);
+            g.setColor(Color.LIGHT_GRAY);
+            g.drawString(LEGEND[kind], legendX + 10, 11);
+            legendX += 10 + g.getFontMetrics().stringWidth(LEGEND[kind]) + 10;
+        }
+
+        final boolean recording = ThreadActivityRecorder.isEnabled();
+        if (!recording && ThreadActivityRecorder.cursor() == 0) {
+            g.setColor(Color.GRAY);
+            g.drawString("recording off — press Record to capture thread activity",
+                    LABEL_WIDTH + 8, LEGEND_HEIGHT + ROW_HEIGHT);
+            syncScrollBar(0, 0, true);
+            return;
+        }
+
+        final long[] starts = ThreadActivityRecorder.starts();
+        final long[] ends = ThreadActivityRecorder.ends();
+        final byte[] kinds = ThreadActivityRecorder.kinds();
+        final byte[] rows = ThreadActivityRecorder.rows();
+        final int capacity = ThreadActivityRecorder.capacity();
+
+        // Capture range: oldest retained interval to latest one
+        long latest = 0;
+        long oldest = Long.MAX_VALUE;
+        for (int i = 0; i < capacity; i++) {
+            if (starts[i] != 0) {
+                if (ends[i] > latest) {
+                    latest = ends[i];
+                }
+                if (starts[i] < oldest) {
+                    oldest = starts[i];
+                }
+            }
+        }
+        if (latest == 0) {
+            return;
+        }
+        if (oldest > latest - windowNanos) {
+            oldest = latest - windowNanos;
+        }
+
+        // Live edge while recording; frozen offset afterwards
+        if (recording) {
+            scrollBackNanos = 0;
+        }
+        final long maxScrollBack = latest - oldest - windowNanos;
+        scrollBackNanos = Math.max(0, Math.min(scrollBackNanos, Math.max(0, maxScrollBack)));
+
+        final long windowEnd = latest - scrollBackNanos;
+        final long windowStart = windowEnd - windowNanos;
+        final double nanosPerPixel = (double) windowNanos / trackWidth;
+
+        final int rowCount = ThreadActivityRecorder.rowCount();
+
+        // Visual row order: "render" and "present" pinned to the top
+        // (they answer "where is the serial time" and "where is the
+        // display time"), workers below in recorder-row order. Recorder
+        // rows are assigned once per thread name, so this order is
+        // stable for the whole capture.
+        int renderRow = -1;
+        int presentRow = -1;
+        for (int row = 0; row < rowCount; row++) {
+            final String name = ThreadActivityRecorder.rowName(row);
+            if ("render".equals(name)) {
+                renderRow = row;
+            } else if ("present".equals(name)) {
+                presentRow = row;
+            }
+        }
+        final int[] visualOf = new int[rowCount];
+        int nextVisual = 0;
+        if (renderRow >= 0) {
+            visualOf[renderRow] = nextVisual++;
+        }
+        if (presentRow >= 0) {
+            visualOf[presentRow] = nextVisual++;
+        }
+        for (int row = 0; row < rowCount; row++) {
+            if (row != renderRow && row != presentRow) {
+                visualOf[row] = nextVisual++;
+            }
+        }
+
+        // Row labels and separators. Workers get sequential CPU numbers
+        // by visual position — the thread-name suffix (worker-27 etc.)
+        // is just a pool counter and carries no meaning.
+        int cpuNumber = 0;
+        final String[] labels = new String[rowCount];
+        for (int row = 0; row < rowCount; row++) {
+            if (row == renderRow) {
+                labels[row] = "render";
+            } else if (row == presentRow) {
+                labels[row] = "present";
+            } else {
+                labels[row] = String.format("CPU %02d", ++cpuNumber);
+            }
+        }
+        g.setColor(Color.DARK_GRAY);
+        for (int row = 0; row < rowCount; row++) {
+            final int y = LEGEND_HEIGHT + visualOf[row] * ROW_HEIGHT;
+            g.drawLine(LABEL_WIDTH, y + ROW_HEIGHT - 1, width, y + ROW_HEIGHT - 1);
+            g.setColor(Color.GRAY);
+            g.drawString(labels[row], 4, y + ROW_HEIGHT - 3);
+            g.setColor(Color.DARK_GRAY);
+        }
+
+        // Intervals
+        for (int i = 0; i < capacity; i++) {
+            final long t0 = starts[i];
+            if (t0 == 0 || ends[i] < windowStart || t0 > windowEnd) {
+                continue;
+            }
+            final int kind = kinds[i];
+            if (kind < 0 || kind >= KIND_COLORS.length) {
+                continue;
+            }
+            final int x0 = LABEL_WIDTH + (int) ((Math.max(t0, windowStart) - windowStart) / nanosPerPixel);
+            final int x1 = LABEL_WIDTH + (int) ((Math.min(ends[i], windowEnd) - windowStart) / nanosPerPixel);
+            g.setColor(KIND_COLORS[kind]);
+            g.fillRect(x0, LEGEND_HEIGHT + visualOf[rows[i]] * ROW_HEIGHT + 1,
+                    Math.max(1, x1 - x0), ROW_HEIGHT - 2);
+        }
+
+        // Time grid: pick a tick spacing that keeps ~10 ticks in view
+        long tickSpacing = 1_000_000L; // 1 ms
+        while (windowNanos / tickSpacing > 20) {
+            tickSpacing *= 10;
+        }
+        while (windowNanos / tickSpacing < 5 && tickSpacing > 100_000L) {
+            tickSpacing /= 10;
+        }
+        g.setColor(new Color(40, 40, 40));
+        final long firstTick = (windowStart / tickSpacing + 1) * tickSpacing;
+        for (long tick = firstTick; tick < windowEnd; tick += tickSpacing) {
+            final int x = LABEL_WIDTH + (int) ((tick - windowStart) / nanosPerPixel);
+            g.drawLine(x, LEGEND_HEIGHT, x, LEGEND_HEIGHT + rowCount * ROW_HEIGHT);
+        }
+
+        // Window size + frozen-offset readout, bottom right of the track
+        g.setColor(Color.YELLOW);
+        final String readout = (windowNanos >= 1_000_000_000L
+                ? String.format("%.1f s", windowNanos / 1e9)
+                : String.format("%.0f ms", windowNanos / 1e6))
+                + (recording ? " live" : String.format("  -%.2f s", scrollBackNanos / 1e9));
+        g.drawString(readout, width - g.getFontMetrics().stringWidth(readout) - 6,
+                LEGEND_HEIGHT + rowCount * ROW_HEIGHT - 4);
+
+        syncScrollBar((int) (scrollBackNanos / 1_000_000L),
+                (int) (Math.max(0, maxScrollBack) / 1_000_000L) + (int) (windowNanos / 1_000_000L),
+                recording);
+    }
+
+    /**
+     * Pushes the current scroll state into the scrollbar model without
+     * feeding back into the adjustment listener.
+     *
+     * @param valueMs   current scroll-back offset in milliseconds
+     * @param maxMs     maximum scroll-back offset plus window size
+     * @param disabled  true to grey out the scrollbar (live recording)
+     */
+    private void syncScrollBar(final int valueMs, final int maxMs, final boolean disabled) {
+        final int visibleMs = Math.max(1, (int) (windowNanos / 1_000_000L));
+        updatingScrollBar = true;
+        try {
+            scrollBar.setEnabled(!disabled && maxMs > visibleMs);
+            scrollBar.setValues(Math.min(valueMs, maxMs), visibleMs, 0, Math.max(visibleMs, maxMs));
+        } finally {
+            updatingScrollBar = false;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewFrame.java
new file mode 100755 (executable)
index 0000000..2dfe340
--- /dev/null
@@ -0,0 +1,345 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.awt.event.WindowEvent;
+import java.awt.event.WindowListener;
+
+/**
+ * Convenience window (JFrame) that creates and hosts a {@link ViewPanel} for 3D rendering.
+ *
+ * <p>This is the simplest way to get a 3D view up and running. The frame starts
+ * maximized, enforces a minimum size of 400x400, and handles window lifecycle
+ * events (minimizing, restoring, closing) automatically.</p>
+ *
+ * <p><b>Quick start:</b></p>
+ * <pre>{@code
+ * // Create a window with a 3D view
+ * ViewFrame frame = new ViewFrame();
+ *
+ * // Access the view panel to add shapes and configure the scene
+ * ViewPanel viewPanel = frame.getViewPanel();
+ * viewPanel.getRootShapeCollection().addShape(
+ *     new WireframeCube(new Point3D(0, 0, 200), 50,
+ *         new LineAppearance(5, Color.GREEN))
+ * );
+ *
+ * // To close programmatically:
+ * frame.exit();
+ * }</pre>
+ *
+ * @see ViewPanel the embedded 3D rendering panel
+ */
+public class ViewFrame extends JFrame implements WindowListener {
+
+    private static final long serialVersionUID = -7037635097739548470L;
+
+    /** The embedded 3D view panel. */
+    private final ViewPanel viewPanel;
+
+    /** Whether the frame is currently in fullscreen exclusive mode. */
+    private boolean fullscreen = false;
+
+    /** Saved window bounds before entering fullscreen, used to restore on exit. */
+    private Rectangle previousBounds;
+
+    /** Saved extended state before entering fullscreen. */
+    private int previousExtendedState;
+
+    /**
+     * Creates a new maximized window with a 3D view.
+     */
+    public ViewFrame() {
+        this("3D engine", -1, -1, true);
+    }
+
+    /**
+     * Creates a new maximized window with a 3D view and custom title.
+     *
+     * @param title the window title to display
+     */
+    public ViewFrame(final String title) {
+        this(title, -1, -1, true);
+    }
+
+    /**
+     * Creates a new window with a 3D view at the specified size.
+     *
+     * @param width  window width in pixels, or -1 for default
+     * @param height window height in pixels, or -1 for default
+     */
+    public ViewFrame(final int width, final int height) {
+        this("3D engine", width, height, false);
+    }
+
+    /**
+     * Creates a new window with a 3D view at the specified size with a custom title.
+     *
+     * @param title  the window title to display
+     * @param width  window width in pixels, or -1 for default
+     * @param height window height in pixels, or -1 for default
+     */
+    public ViewFrame(final String title, final int width, final int height) {
+        this(title, width, height, false);
+    }
+
+    private ViewFrame(final String title, final int width, final int height, final boolean maximize) {
+        setTitle(title);
+
+        addWindowListener(new java.awt.event.WindowAdapter() {
+            @Override
+            public void windowClosing(final java.awt.event.WindowEvent e) {
+                exit();
+            }
+        });
+
+        viewPanel = new ViewPanel();
+
+        add(getViewPanel());
+
+        if (width > 0 && height > 0) {
+            setSize(width, height);
+        } else {
+            setSize(800, 600);
+        }
+
+        if (maximize) {
+            setExtendedState(JFrame.MAXIMIZED_BOTH);
+        }
+        setVisible(true);
+        validate();
+
+        addResizeListener();
+        addWindowListener(this);
+    }
+
+    private void addResizeListener() {
+        addComponentListener(new ComponentListener() {
+            // This method is called after the component's size changes
+            @Override
+            public void componentHidden(final ComponentEvent e) {
+            }
+
+            @Override
+            public void componentMoved(final ComponentEvent e) {
+            }
+
+            @Override
+            public void componentResized(final ComponentEvent evt) {
+
+                final Component c = (Component) evt.getSource();
+
+                // Get new size
+                final Dimension newSize = c.getSize();
+
+                boolean sizeFixed = false;
+
+                if (newSize.width < 400) {
+                    newSize.width = 400;
+                    sizeFixed = true;
+                }
+
+                if (newSize.height < 400) {
+                    newSize.height = 400;
+                    sizeFixed = true;
+                }
+
+                if (sizeFixed)
+                    setSize(newSize);
+
+            }
+
+            @Override
+            public void componentShown(final ComponentEvent e) {
+                viewPanel.repaintDuringNextViewUpdate();
+            }
+
+        });
+    }
+
+    /**
+     * Exit the application.
+     */
+    public void exit() {
+        if (getViewPanel() != null) {
+            getViewPanel().stop();
+            getViewPanel().setEnabled(false);
+            getViewPanel().setVisible(false);
+        }
+        dispose();
+    }
+
+    @Override
+    public java.awt.Dimension getPreferredSize() {
+        return new java.awt.Dimension(640, 480);
+    }
+
+    /**
+     * Returns the embedded {@link ViewPanel} for adding shapes and configuring the scene.
+     *
+     * @return the view panel contained in this frame
+     */
+    public ViewPanel getViewPanel() {
+        return viewPanel;
+    }
+
+    /**
+     * Returns whether the frame is currently in fullscreen exclusive mode.
+     *
+     * @return {@code true} if the frame is in fullscreen exclusive mode
+     */
+    public boolean isFullscreen() {
+        return fullscreen;
+    }
+
+    /**
+     * Enters or exits fullscreen exclusive mode (FSEM).
+     *
+     * <p>Fullscreen always targets the screen the window is currently
+     * placed on (its {@link GraphicsConfiguration#getDevice()}), so a
+     * window dragged onto e.g. XR glasses goes fullscreen there, not on
+     * the primary monitor. The default screen device is only a fallback
+     * for a not-yet-displayed frame.</p>
+     *
+     * <p>When entering fullscreen, the current window bounds and extended state are
+     * saved so they can be restored on exit. The frame is passed to the screen
+     * device as the fullscreen window, which automatically removes decorations and
+     * covers the entire screen. No explicit {@code setUndecorated()} call is needed —
+     * in fact, calling it would be harmful because it requires {@code dispose()} which
+     * destroys the Canvas and its BufferStrategy.</p>
+     *
+     * <p>When exiting fullscreen, the frame is released from the screen device,
+     * decorations are restored automatically, and previous bounds/extended state
+     * are restored.</p>
+     *
+     * <p>This method is idempotent: setting fullscreen to its current value
+     * is a no-op and returns {@code true}.</p>
+     *
+     * @param on {@code true} to enter fullscreen, {@code false} to exit fullscreen
+     * @return {@code true} if the state changed or was already as requested, {@code false}
+     *         if the transition could not be performed (e.g. unsupported graphics device)
+     */
+    public boolean setFullscreen(final boolean on) {
+        if (this.fullscreen == on)
+            return true;
+
+        GraphicsDevice device = null;
+        final GraphicsConfiguration configuration = getGraphicsConfiguration();
+        if (configuration != null)
+            device = configuration.getDevice();
+        if (device == null)
+            device = GraphicsEnvironment.getLocalGraphicsEnvironment()
+                    .getDefaultScreenDevice();
+
+        if (!device.isFullScreenSupported())
+            return false;
+
+        try {
+            if (on) {
+                // Save current state before entering fullscreen
+                previousBounds = getBounds();
+                previousExtendedState = getExtendedState();
+
+                // GraphicsDevice.setFullScreenWindow() automatically removes
+                // decorations when entering FSEM and restores them on exit.
+                // Do NOT call setUndecorated() here — it requires the frame to be
+                // non-displayable (dispose first) which destroys the Canvas and
+                // its BufferStrategy, breaking the render thread.
+                device.setFullScreenWindow(this);
+                fullscreen = true;
+            } else {
+                // Exit fullscreen exclusive mode.
+                // Decorations are restored automatically by the graphics device.
+                device.setFullScreenWindow(null);
+
+                // Restore previous bounds and extended state
+                if (previousBounds != null) {
+                    setBounds(previousBounds);
+                }
+                setExtendedState(previousExtendedState);
+
+                fullscreen = false;
+
+                // Trigger a repaint since the rendering surface changed
+                viewPanel.repaintDuringNextViewUpdate();
+            }
+        } catch (final Exception e) {
+            // Something went wrong — revert if we were trying to enter
+            if (on) {
+                try {
+                    device.setFullScreenWindow(null);
+                    if (previousBounds != null)
+                        setBounds(previousBounds);
+                    setExtendedState(previousExtendedState);
+                } catch (final Exception ignored) {
+                }
+            }
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Toggles between fullscreen exclusive mode and windowed mode.
+     *
+     * <p>Equivalent to {@code setFullscreen(!isFullscreen())}.</p>
+     *
+     * @return {@code true} if the toggle succeeded
+     * @see #setFullscreen(boolean)
+     * @see #isFullscreen()
+     */
+    public boolean toggleFullscreen() {
+        return setFullscreen(!fullscreen);
+    }
+
+    @Override
+    public void windowActivated(final WindowEvent e) {
+        viewPanel.repaintDuringNextViewUpdate();
+        viewPanel.requestFocus();
+    }
+
+    @Override
+    public void windowClosed(final WindowEvent e) {
+    }
+
+    @Override
+    public void windowClosing(final WindowEvent e) {
+    }
+
+    @Override
+    public void windowDeactivated(final WindowEvent e) {
+    }
+
+    /**
+     * Repaint the view when the window is deiconified.
+     *
+     * Deiconified means that the window is restored from minimized state.
+     */
+    @Override
+    public void windowDeiconified(final WindowEvent e) {
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    /**
+     * Do nothing when the window is iconified.
+     *
+     * Iconified means that the window is minimized.
+     * @param e the event to be processed
+     */
+    @Override
+    public void windowIconified(final WindowEvent e) {
+    }
+
+    @Override
+    public void windowOpened(final WindowEvent e) {
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java
new file mode 100755 (executable)
index 0000000..065f3b5
--- /dev/null
@@ -0,0 +1,1612 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.diag.Diagnostics;
+import eu.svjatoslav.aukio.e3d.diag.EngineConfig;
+import eu.svjatoslav.aukio.e3d.diag.Telemetry;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadLookController;
+import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker;
+import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTrackingManager;
+import eu.svjatoslav.aukio.e3d.gui.headtrack.RayNeoHid;
+import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceMouseManager;
+import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+
+import java.awt.*;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.image.BufferStrategy;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * AWT Canvas that provides a 3D rendering surface with built-in camera navigation.
+ *
+ * <p>{@code ViewPanel} is the primary entry point for embedding the Aukio 3D engine into
+ * a Java application. It manages the render loop, maintains a scene graph
+ * ({@link ShapeCollection}), and handles user input for camera navigation.</p>
+ *
+ * <p>Uses {@link BufferStrategy} for efficient page-flipping and tear-free rendering.</p>
+ *
+ * <p><b>Quick start - creating a 3D view in a window:</b></p>
+ * <pre>{@code
+ * // Option 1: Use ViewFrame (creates a maximized JFrame for you)
+ * ViewFrame frame = new ViewFrame();
+ * ViewPanel viewPanel = frame.getViewPanel();
+ *
+ * // Option 2: Embed ViewPanel in your own window
+ * JFrame frame = new JFrame("My 3D App");
+ * ViewPanel viewPanel = new ViewPanel();
+ * frame.add(viewPanel);
+ * frame.setSize(800, 600);
+ * frame.setVisible(true);
+ *
+ * // Add shapes to the scene
+ * ShapeCollection scene = viewPanel.getRootShapeCollection();
+ * scene.addShape(new WireframeCube(
+ *     new Point3D(0, 0, 200), 50,
+ *     new LineAppearance(5, Color.GREEN)
+ * ));
+ *
+ * // Position the camera
+ * viewPanel.getCamera().setLocation(new Point3D(0, 0, -100));
+ *
+ * // Listen for frame updates (e.g., for animations)
+ * viewPanel.addFrameListener((panel, deltaMs) -> {
+ *     // Called before each frame. Return true to force repaint.
+ *     return false;
+ * });
+ * }</pre>
+ *
+ * <p><b>Architecture:</b></p>
+ * <ul>
+ *   <li>A background render thread continuously generates frames at the target FPS</li>
+ *   <li>The engine intelligently skips rendering when no visual changes are detected</li>
+ *   <li>{@link FrameListener}s are notified before each potential frame, enabling animations</li>
+ *   <li>Mouse/keyboard input is managed by {@link InputManager}</li>
+ *   <li>Keyboard focus is managed by {@link KeyboardFocusStack}</li>
+ * </ul>
+ *
+ * @see ViewFrame convenience window wrapper
+ * @see ShapeCollection the scene graph
+ * @see Camera the camera/viewer
+ * @see FrameListener for per-frame callbacks
+ */
+public class ViewPanel extends Canvas {
+    private static final long serialVersionUID = 1683277888885045387L;
+    private static final int NUM_BUFFERS = 2;
+
+    /** The input manager handling mouse and keyboard events. */
+    private final InputManager inputManager = new InputManager(this);
+    /** The stack managing keyboard focus for GUI components. */
+    private final KeyboardFocusStack keyboardFocusStack;
+    /** The camera representing the viewer's position and orientation. */
+    private final Camera camera = new Camera();
+    /** Head tracker hot-plug manager, unless disabled via e3d.headtrack=false. */
+    private HeadTrackingManager headTrackingManager;
+
+    /** SpaceNavigator hot-plug manager, unless disabled via e3d.spacemouse=false. */
+    private SpaceMouseManager spaceMouseManager;
+    /** The root shape collection containing all 3D shapes in the scene. */
+    private final ShapeCollection rootShapeCollection = new ShapeCollection();
+    /** The set of frame listeners notified before each frame. */
+    private final Set<FrameListener> frameListeners = ConcurrentHashMap.newKeySet();
+    /**
+     * Number of paint tiles per render thread. Tiles (not threads) are the
+     * unit of work: threads steal tiles off a shared ticket, so more tiles
+     * than threads lets fast threads pick up remaining tiles instead of
+     * idling behind a busy one. Measured 2026-09-04 (400-sphere scene,
+     * 1920x1080, HX 370): 10x oversampling beats 4x by ~15% and also
+     * beats pure horizontal bands; 16x adds a further win only on
+     * spatially clustered scenes.
+     */
+    private static final int TILES_PER_THREAD = 10;
+
+    /** The executor service for parallel rendering. */
+    private ExecutorService renderExecutor = Executors.newFixedThreadPool(defaultRenderThreadCount());
+    /** Number of render threads. Can be changed at runtime via {@link #setNumRenderThreads(int)}. */
+    private volatile int numRenderThreads = defaultRenderThreadCount();
+    /**
+     * Executor for the parallel transform phase, sized to all available cores.
+     * Created lazily on the render thread; daemon threads so it never blocks JVM exit.
+     */
+    private ExecutorService transformExecutor = null;
+    /** The background color of the view. */
+    public Color backgroundColor = Color.BLACK;
+
+    /** Developer tools for this view panel. */
+    private final DeveloperTools developerTools = new DeveloperTools();
+    /** Debug log buffer for capturing diagnostic output. */
+    private final DebugLogBuffer debugLogBuffer = new DebugLogBuffer(10000);
+    /** The developer tools panel popup, or null if not currently shown. */
+    private DeveloperToolsPanel developerToolsPanel = null;
+
+    /**
+     * Global lighting manager for the scene.
+     * Contains all light sources and ambient light settings. Shaded polygons
+     * access this via the RenderingContext during paint(). Add lights here
+     * to illuminate the world.
+     */
+    private final LightingManager lightingManager = new LightingManager();
+
+    /** Progressive GI system, created by {@link #enableGlobalIllumination()}. */
+    private eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination globalIllumination;
+
+    /**
+     * Stores milliseconds when the last frame was updated. This is needed to calculate the time delta between frames.
+     * Time delta is used to calculate smooth animation.
+     */
+    private long lastUpdateMillis = 0;
+
+    /** The current rendering context for the active frame. */
+    private RenderingContext renderingContext = null;
+
+    /**
+     * Double-buffered frame contexts, indexed by frame parity. While frame N
+     * is still being painted from one buffer, frame N+1 already transforms
+     * into the other, so paint threads never idle waiting for the transform
+     * phase and transform threads never wait for the last tile.
+     */
+    private final RenderingContext[] frameContexts = new RenderingContext[3];
+
+    /** Parity (0/1) of the frame currently being prepared. */
+    private int frameParity = 0;
+
+    /**
+     * Counts render passes (one per eye in stereo). A pass's projection
+     * buffer slot is {@code passCounter % 3}: with three slots, transform
+     * of pass P only conflicts with paint of pass P-3, so it can run
+     * while the two previous passes' paints are still in flight.
+     */
+    private long passCounter = 0;
+
+    /**
+     * Paint passes that were submitted to the shared executor but not yet
+     * awaited, oldest first. Depth stays at most 2: the pass being
+     * transformed now overlaps the previously submitted one, and freed
+     * workers flow from the tail of the older pass's tiles straight into
+     * the newer pass's tiles — consecutive passes' paints overlap on
+     * purpose (they write different parity framebuffers and read
+     * different parity vertex slots, so no ordering between them is
+     * required).
+     */
+    private final java.util.ArrayDeque<PendingPaint> pendingPaints = new java.util.ArrayDeque<>();
+
+    /**
+     * Mailbox holding the newest completed frame awaiting presentation.
+     * Only the latest frame is kept: when the display path (X server at
+     * 4K, tens of MB per blit) is slower than production, stale frames
+     * are dropped instead of piling up latency — swapchain "mailbox
+     * mode". The render thread never blocks on the display: it deposits
+     * here and the dedicated present thread does the blitting.
+     */
+    private final java.util.concurrent.atomic.AtomicReference<PresentJob> mailboxFrame =
+            new java.util.concurrent.atomic.AtomicReference<>();
+
+    /** Signals the present thread that a frame was deposited. */
+    private final java.util.concurrent.Semaphore presentSignal = new java.util.concurrent.Semaphore(0);
+
+    /** Daemon thread performing all BufferStrategy blits. */
+    private Thread presentThread;
+
+    /** Run flag for the present thread. */
+    private volatile boolean presentThreadRunning = false;
+
+    /**
+     * Maximum blits per second the present thread issues IN CAPPED-FPS
+     * MODE. Exists because the X server also dispatches input: flooding
+     * it with blits causes desktop-wide mouse/keyboard jitter. Override
+     * with {@code -Daukio3d.presentRate=N} for high-refresh displays.
+     *
+     * <p>In benchmark mode (targetFPS &lt;= 0) pacing is DISABLED: the
+     * pacing sleep delays the presented frame's gate release, and since
+     * paint(F+3) awaits that gate before reusing F's framebuffer, pacing
+     * the present thread used to cap PRODUCTION to roughly
+     * 2&times;PRESENT_RATE_LIMIT_FPS (measured: unlimited-FPS mode
+     * locked to ~120 FPS with 18 workers 70% idle, 2026-09-06).</p>
+     */
+    private static final int PRESENT_RATE_LIMIT_FPS =
+            Integer.parseInt(System.getProperty("aukio3d.presentRate", "60"));
+
+    /**
+     * When false, each paint pass is awaited immediately after submission,
+     * restoring the old strictly-sequential phase order. Kill switch for
+     * benchmarking and regression hunting; also toggled at runtime from
+     * developer tools if needed.
+     */
+    private volatile boolean pipelineEnabled =
+            !"false".equalsIgnoreCase(System.getProperty("aukio3d.pipeline", "true"));
+
+    /**
+     * A paint pass whose tiles are still being worked on by the render
+     * executor. Awaited one pass later (software pipeline).
+     */
+    /** A completed frame plus the gate to fire once it is presented or dropped. */
+    private static final class PresentJob {
+        RenderingContext context;
+        java.util.concurrent.CountDownLatch gate;
+    }
+
+    private static class PendingPaint {
+        volatile CountDownLatch latch;
+        RenderingContext context;
+        volatile SegmentRenderingContext[] segmentContexts;
+        int eyeOffsetX;
+        int eyeWidth;
+        /** True when this pass completes its frame (blit becomes due). */
+        boolean lastPassOfFrame;
+
+        /** Global pass index at submission time (slot = index mod 3). */
+        long passIndex;
+
+        /** The frame's own present gate, fired when it is presented or dropped. */
+        java.util.concurrent.CountDownLatch frameGate;
+
+        /**
+         * Counted down by the pass's asynchronous continuation once the
+         * paint tasks have been submitted and {@link #latch} /
+         * {@link #segmentContexts} are published.
+         */
+        final java.util.concurrent.CountDownLatch ready = new java.util.concurrent.CountDownLatch(1);
+    }
+
+    /**
+     * Currently target frames per second rate for this view. Target FPS can be changed at runtime.
+     * 3D engine tries to be smart and only repaints screen when there are visible changes.
+     * A value of 0 or less means unlimited FPS (benchmark mode).
+     */
+    private volatile int targetFPS = 60;
+
+    /** Frames blitted in the current FPS measurement window (render thread only). */
+    private int fpsWindowFrames = 0;
+
+    /** Start of the current FPS measurement window, nanoseconds (render thread only). */
+    private long fpsWindowStartNanos = 0;
+
+    /** Most recently measured display rate (frames blitted per second). */
+    private volatile double measuredFPS = 0;
+
+    /**
+     * Set to true if it is known than next frame needs to be painted. Flag is cleared
+     * immediately after frame got updated.
+     */
+    private boolean viewRepaintNeeded = true;
+
+    /**
+     * Render thread that runs the continuous frame generation loop.
+     */
+    private Thread renderThread;
+
+    /**
+     * Flag to control whether the render thread should keep running.
+     */
+    private volatile boolean renderThreadRunning = false;
+
+    /** Timestamp for the next scheduled frame. */
+    private long nextFrameTime;
+
+    /**
+     * The most recently completed frame, retained so a bug report can
+     * include a screenshot of what the user was seeing. Set by
+     * {@link #presentFrame}; the buffer is reused by the pipeline, so
+     * consumers must copy it.
+     */
+    private volatile java.awt.image.BufferedImage lastFrameImage;
+
+    /** The buffer strategy for page-flipping rendering (also read by the present thread). */
+    private BufferStrategy bufferStrategy;
+
+    /** Whether the buffer strategy has been initialized. */
+    private boolean bufferStrategyInitialized = false;
+
+    /**
+     * Creates a new view panel with default settings.
+     */
+    public ViewPanel() {
+        frameListeners.add(camera);
+        frameListeners.add(inputManager);
+
+        // persistent log + telemetry (idempotent); the view telemetry
+        // source reports frame production stats
+        Diagnostics.install();
+        Telemetry.registerSource("view", () -> String.format(
+                "fps=%.1f targetFps=%d size=%dx%d renderThreads=%d",
+                getMeasuredFPS(), getTargetFPS(), getWidth(), getHeight(),
+                getNumRenderThreads()));
+
+        keyboardFocusStack = new KeyboardFocusStack(this);
+
+        initializeCanvas();
+        initializeHeadTracking();
+        initializeSpaceMouse();
+
+        // Set default ambient light for the scene
+        lightingManager.setAmbientLight(new Color(50, 50, 50));
+        addComponentListener(new ComponentAdapter() {
+            @Override
+            public void componentResized(final ComponentEvent e) {
+                viewRepaintNeeded = true;
+                startRenderThreadIfReady();
+            }
+
+            @Override
+            public void componentShown(final ComponentEvent e) {
+                viewRepaintNeeded = true;
+                startRenderThreadIfReady();
+            }
+        });
+    }
+
+    private void startRenderThreadIfReady() {
+        if (isShowing() && getWidth() > 0 && getHeight() > 0)
+            startRenderThread();
+    }
+
+    /**
+     * Returns the camera representing the viewer's position and orientation.
+     *
+     * @return the camera
+     */
+    public Camera getCamera() {
+        return camera;
+    }
+
+    /**
+     * Returns the keyboard focus stack, which manages which component receives
+     * keyboard input.
+     *
+     * @return the keyboard focus stack
+     */
+    public KeyboardFocusStack getKeyboardFocusStack() {
+        return keyboardFocusStack;
+    }
+
+    /**
+     * Returns the root shape collection (scene graph). Add your 3D shapes here
+     * to make them visible in the view.
+     *
+     * <pre>{@code
+     * viewPanel.getRootShapeCollection().addShape(myShape);
+     * }</pre>
+     *
+     * @return the root shape collection
+     */
+    public ShapeCollection getRootShapeCollection() {
+        return rootShapeCollection;
+    }
+
+    /**
+     * Returns the human input device (mouse/keyboard) event tracker.
+     *
+     * @return the HID event tracker
+     */
+    /**
+     * Returns the input manager handling mouse and keyboard events for this view.
+     *
+     * @return the input manager
+     */
+    public InputManager getInputManager() {
+        return inputManager;
+    }
+
+    /**
+     * Registers a listener that will be notified before each frame render.
+     * Listeners can trigger repaints by returning {@code true} from
+     * {@link FrameListener#onFrame}.
+     *
+     * @param listener the listener to add
+     * @see #removeFrameListener(FrameListener)
+     */
+    public void addFrameListener(final FrameListener listener) {
+        frameListeners.add(listener);
+    }
+
+    @Override
+    public Dimension getPreferredSize() {
+        return new Dimension(640, 480);
+    }
+
+    @Override
+    public Dimension getMinimumSize() {
+        return getPreferredSize();
+    }
+
+    @Override
+    public Dimension getMaximumSize() {
+        return getPreferredSize();
+    }
+
+    /**
+     * Returns the current rendering context for the active frame.
+     *
+     * @return the rendering context, or null if no frame is being rendered
+     */
+    public RenderingContext getRenderingContext() {
+        return renderingContext;
+    }
+
+    /**
+     * Returns the developer tools for this view panel.
+     *
+     * @return the developer tools
+     */
+    public DeveloperTools getDeveloperTools() {
+        return developerTools;
+    }
+
+    /**
+     * Returns the debug log buffer for this view panel.
+     *
+     * @return the debug log buffer
+     */
+    public DebugLogBuffer getDebugLogBuffer() {
+        return debugLogBuffer;
+    }
+
+    /**
+     * Returns the most recently completed frame, for bug report
+     * screenshots. The buffer belongs to the render pipeline and is
+     * reused — copy it before use. Null until the first frame completes.
+     *
+     * @return the last presented frame image, or null
+     */
+    public java.awt.image.BufferedImage getLastFrameImage() {
+        return lastFrameImage;
+    }
+
+    /**
+     * Returns the global lighting manager for the scene.
+     * Add light sources here to illuminate the world.
+     *
+     * @return the lighting manager
+     */
+    public LightingManager getLightingManager() {
+        return lightingManager;
+    }
+
+    /**
+     * Enables progressive global illumination with the default of 2
+     * dedicated low-priority CPU threads. GI is opt-in: without this call
+     * lighting behaves exactly as before. Shadows and bounced light fade in
+     * over the first seconds and keep adapting to scene changes.
+     *
+     * @return the running GI system
+     */
+    public eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination enableGlobalIllumination() {
+        return enableGlobalIllumination(2);
+    }
+
+    /**
+     * Enables progressive global illumination with the given number of
+     * dedicated low-priority CPU threads. Calling again returns the
+     * already-running system.
+     *
+     * @param threadCount worker threads for ray tracing
+     * @return the running GI system
+     */
+    public synchronized eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination enableGlobalIllumination(
+            final int threadCount) {
+        if (globalIllumination == null) {
+            globalIllumination = new eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination(
+                    rootShapeCollection, lightingManager, threadCount);
+            globalIllumination.start();
+        }
+        return globalIllumination;
+    }
+
+    /**
+     * Shows the developer tools panel, toggling it if already open.
+     * Called when F12 is pressed.
+     */
+    public void showDeveloperToolsPanel() {
+        if (developerToolsPanel != null && developerToolsPanel.isVisible()) {
+            developerToolsPanel.dispose();
+            developerToolsPanel = null;
+            return;
+        }
+
+        Frame parentFrame = null;
+        Container parent = getParent();
+        while (parent != null) {
+            if (parent instanceof Frame) {
+                parentFrame = (Frame) parent;
+                break;
+            }
+            parent = parent.getParent();
+        }
+
+        developerToolsPanel = new DeveloperToolsPanel(parentFrame, this, developerTools, debugLogBuffer);
+        developerToolsPanel.setVisible(true);
+    }
+
+    @Override
+    public void paint(final Graphics g) {
+    }
+
+    @Override
+    public void update(final Graphics g) {
+    }
+
+    private void initializeCanvas() {
+        setBackground(java.awt.Color.BLACK);
+        setFocusable(true);
+        // The canvas is the only focusable component in the frame; AWT
+        // focus traversal is meaningless here. Without this, AWT consumes
+        // Tab/Shift+Tab (and Ctrl+Tab) as traversal keys BEFORE they reach
+        // KeyListeners, so widgets (terminal, text editor Tab-indent)
+        // never see them.
+        setFocusTraversalKeysEnabled(false);
+        setIgnoreRepaint(true);
+        setVisible(true);
+    }
+
+    @Override
+    public void addNotify() {
+        super.addNotify();
+        requestFocus();
+    }
+
+    private void ensureBufferStrategy() {
+        if (bufferStrategyInitialized && bufferStrategy != null)
+            return;
+
+        if (!isDisplayable() || getWidth() <= 0 || getHeight() <= 0)
+            return;
+
+        try {
+            createBufferStrategy(NUM_BUFFERS);
+            bufferStrategy = getBufferStrategy();
+            if (bufferStrategy != null) {
+                bufferStrategyInitialized = true;
+                // Prime the buffer strategy with an initial show() to ensure it's ready
+                Graphics2D g = null;
+                try {
+                    g = (Graphics2D) bufferStrategy.getDrawGraphics();
+                    if (g != null) {
+                        g.setColor(java.awt.Color.BLACK);
+                        g.fillRect(0, 0, getWidth(), getHeight());
+                    }
+                } finally {
+                    if (g != null) g.dispose();
+                }
+                bufferStrategy.show();
+                java.awt.Toolkit.getDefaultToolkit().sync();
+            }
+        } catch (final Exception e) {
+            bufferStrategy = null;
+            bufferStrategyInitialized = false;
+        }
+    }
+
+    private static int renderFrameCount = 0;
+
+    private void renderFrame() {
+        ensureBufferStrategy();
+        ensureExecutorMatchesThreadCount();
+
+        if (bufferStrategy == null || renderingContext == null) {
+            debugLogBuffer.log("[VIEWPANEL] renderFrame ABORT: bufferStrategy=" + bufferStrategy + ", renderingContext=" + renderingContext);
+            return;
+        }
+
+        renderFrameCount++;
+        ThreadActivityRecorder.setFrameParity(frameParity);
+
+        // Install this frame-use's present gate and capture the previous
+        // one: this frame's paint continuation will await the previous
+        // gate before writing pixels, so painting never overwrites a
+        // buffer the present thread is still blitting from. The frame's
+        // OWN gate travels with the PendingPaint to the deposit — the
+        // context field is reset again by the next frame reusing this
+        // context, long before this frame's flush reads it.
+        final java.util.concurrent.CountDownLatch previousGate = renderingContext.presentGate;
+        final java.util.concurrent.CountDownLatch frameGate = new java.util.concurrent.CountDownLatch(1);
+        renderingContext.presentGate = frameGate;
+
+        try {
+            // Triple-buffered software pipeline, one render pass per eye.
+            // Vertex state, aggregators and framebuffers cycle through 3
+            // slots, so transform(pass P) only needs paint(P-3) to be
+            // complete — it can run while the two previous passes' paints
+            // are still on the executor. Before each transform, completed
+            // paints are flushed (mouse hits, frame blit) and the render
+            // thread blocks ONLY if the pass P-3 paint is still running,
+            // which steady-state worker throughput prevents. Workers flow
+            // from one pass's tiles straight into the next pass's tiles
+            // with no gap: the next paint is always already queued.
+            if (stereoModeEnabled) {
+                final int eyeWidth = renderingContext.width / 2;
+                flushCompletedPasses(passCounter - 3);
+                final RenderingContext leftPass = transformPass(StereoEye.LEFT, eyeWidth, 0);
+                submitPaintPass(StereoEye.LEFT, eyeWidth, 0, false, leftPass, previousGate, frameGate);
+                flushCompletedPasses(passCounter - 3);
+                final RenderingContext rightPass = transformPass(StereoEye.RIGHT, eyeWidth, eyeWidth);
+                submitPaintPass(StereoEye.RIGHT, eyeWidth, eyeWidth, true, rightPass, previousGate, frameGate);
+            } else {
+                flushCompletedPasses(passCounter - 3);
+                final RenderingContext pass = transformPass(StereoEye.NONE, renderingContext.width, 0);
+                submitPaintPass(StereoEye.NONE, renderingContext.width, 0, true, pass, previousGate, frameGate);
+            }
+            frameParity = (frameParity + 1) % 3;
+        } catch (final Exception e) {
+            debugLogBuffer.log("[VIEWPANEL] renderFrame exception: " + e.getMessage());
+            e.printStackTrace();
+            bufferStrategyInitialized = false;
+            bufferStrategy = null;
+        }
+    }
+
+    /**
+     * Blits a finished frame buffer to the screen via the buffer strategy.
+     * The re-blit loop handles OS back-buffer recreation: contentsRestored()
+     * triggers when the OS recreates the back buffer (common during window
+     * creation); since the offscreen bufferedImage still contains the
+     * correct frame data, only a re-blit is needed, never a re-render.
+     *
+     * @param context the frame context whose bufferedImage is complete
+     */
+    /**
+     * Deposits a completed frame into the presentation mailbox and wakes
+     * the present thread. If the previous deposited frame has not been
+     * shown yet, it is dropped: displaying a stale frame when a newer one
+     * exists only adds latency.
+     *
+     * @param context the frame context whose buffer is complete
+     */
+    private void presentFrame(final RenderingContext context,
+                              final java.util.concurrent.CountDownLatch frameGate) {
+        noteFrameBlitted(); // counts PRODUCED frames (benchmark rate)
+        lastFrameImage = context.bufferedImage;
+        final PresentJob job = new PresentJob();
+        job.context = context;
+        job.gate = frameGate;
+        final PresentJob dropped = mailboxFrame.getAndSet(job);
+        if (dropped != null) {
+            // Never shown: release its framebuffer for reuse immediately
+            dropped.gate.countDown();
+        }
+        presentSignal.release();
+    }
+
+    /**
+     * Present thread loop: takes the newest mailbox frame and blits it.
+     * All slow display-path work (33 MB drawImage at 4K, BufferStrategy
+     * show, Toolkit.sync round-trip to the X server) happens here, never
+     * on the render thread that feeds the worker pool.
+     *
+     * <p>Paced to the display rate IN CAPPED-FPS MODE: without a cap, an
+     * unlimited-FPS pipeline floods the X server with hundreds of 33 MB
+     * blits per second, and since the X server also processes mouse and
+     * keyboard, the whole desktop gets input jitter. In benchmark mode
+     * (targetFPS &lt;= 0) pacing is skipped: the sleep would delay the
+     * presented frame's gate release, and gate backpressure
+     * (paint(F+3) awaits blit/drop of F) would cap production to the
+     * present rate — the opposite of what benchmark mode is for. Frames
+     * the present thread cannot keep up with are dropped from the
+     * mailbox at deposit time and their gates fire immediately.</p>
+     */
+    private void presentLoop() {
+        final long presentIntervalNanos = 1_000_000_000L / PRESENT_RATE_LIMIT_FPS;
+        long nextPresent = System.nanoTime();
+        while (presentThreadRunning) {
+            try {
+                presentSignal.acquire();
+            } catch (final InterruptedException e) {
+                break;
+            }
+            final PresentJob job = mailboxFrame.getAndSet(null);
+            if (job != null) {
+                // Only pace when the user asked for a capped frame rate.
+                if (targetFPS > 0) {
+                    nextPresent += presentIntervalNanos;
+                    final long sleep = nextPresent - System.nanoTime();
+                    if (sleep > 0) {
+                        try {
+                            Thread.sleep(sleep / 1_000_000L, (int) (sleep % 1_000_000L));
+                        } catch (final InterruptedException e) {
+                            break;
+                        }
+                    } else if (sleep < -presentIntervalNanos) {
+                        nextPresent = System.nanoTime(); // fell behind: resync
+                    }
+                }
+                try {
+                    blitFrame(job.context, job.gate);
+                } catch (final Throwable t) {
+                    // A blit failure must NEVER kill this thread: every
+                    // frame's present gate depends on it. Drop the buffer
+                    // strategy; the render thread recreates it next frame.
+                    debugLogBuffer.log("[VIEWPANEL] present failed: " + t);
+                    bufferStrategyInitialized = false;
+                    bufferStrategy = null;
+                } finally {
+                    job.gate.countDown();
+                }
+            }
+        }
+    }
+
+    private void blitFrame(final RenderingContext context,
+                           final java.util.concurrent.CountDownLatch gate) {
+        if (bufferStrategy == null)
+            return;
+        final boolean trace = ThreadActivityRecorder.isEnabled();
+        final long t0 = trace ? System.nanoTime() : 0;
+        try {
+        do {
+            Graphics2D g = null;
+            try {
+                g = (Graphics2D) bufferStrategy.getDrawGraphics();
+                if (g != null) {
+                    // Use image observer to ensure proper image loading
+                    g.drawImage(context.bufferedImage, 0, 0, this);
+                }
+            } catch (final Exception e) {
+                debugLogBuffer.log("[VIEWPANEL] Blit exception: " + e.getMessage());
+                break;
+            } finally {
+                if (g != null) g.dispose();
+            }
+        } while (bufferStrategy.contentsRestored());
+
+        // Release the framebuffer for reuse NOW: the draw loop above is
+        // the only part that reads context.bufferedImage. show() and
+        // Toolkit.sync() below touch only the BufferStrategy's own back
+        // buffer and the X connection — at 4920x2960 they cost ~16 ms
+        // (vs ~8 ms for the draw), and holding the frame's gate across
+        // them used to stall the paint pass waiting to reuse this
+        // buffer (measured 2026-09-06: gate hold ~24 ms, production
+        // capped at ~52 FPS). The present thread's finally-block still
+        // fires the gate as a backstop (countDown is idempotent).
+        gate.countDown();
+
+        if (bufferStrategy.contentsLost()) {
+            debugLogBuffer.log("[VIEWPANEL] Buffer contents LOST, reinitializing");
+            bufferStrategyInitialized = false;
+            bufferStrategy = null;
+        } else {
+            bufferStrategy.show();
+            java.awt.Toolkit.getDefaultToolkit().sync();
+        }
+        } finally {
+            if (trace) {
+                ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_BLIT, t0, System.nanoTime());
+            }
+        }
+    }
+
+    /**
+     * Awaits the oldest pending paint pass (if any), processes its mouse
+     * hits, and schedules its frame buffer for blitting when it was the
+     * frame's last pass. Newer paint passes keep running meanwhile — the
+     * await only enforces the constraints that actually exist: vertex
+     * slot reuse (transform P+2 needs paint P done) and blit ordering.
+     */
+    private void flushPendingPaint() {
+        final PendingPaint pending = pendingPaints.poll();
+        if (pending == null)
+            return;
+
+        try {
+            final boolean trace = ThreadActivityRecorder.isEnabled();
+            final long t0 = trace ? System.nanoTime() : 0;
+            pending.ready.await();
+            final CountDownLatch paintLatch = pending.latch;
+            if (paintLatch != null) {
+                paintLatch.await();
+            }
+            if (trace) {
+                ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_AWAIT, t0, System.nanoTime());
+            }
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return;
+        }
+
+        // Hi-Z: fold this frame's depth into the occlusion pyramid the
+        // next frame's block culling runs against. Only on successful
+        // paint (segmentContexts null = the pass failed — keep the
+        // previous pyramid rather than folding in partial depth).
+        if (pending.segmentContexts != null
+                && pending.context.occlusionPyramid != null)
+            pending.context.occlusionPyramid.buildFrom(
+                    pending.context.depth,
+                    pending.context.width, pending.context.height);
+
+        // Only process mouse hits for the eye whose viewport contains the
+        // cursor. In stereo mode each eye sees a different camera position,
+        // so the same object may appear at different screen X in each eye.
+        // The cursor is in absolute screen coordinates and can only be in
+        // one eye's viewport; combining for the wrong eye would overwrite
+        // the correct hit with null.
+        final MouseEvent mouseEvent = pending.context.getMouseEvent();
+        final boolean mouseInThisEye = (mouseEvent == null)
+                || (mouseEvent.coordinate.x >= pending.eyeOffsetX
+                    && mouseEvent.coordinate.x < pending.eyeOffsetX + pending.eyeWidth);
+        // segmentContexts is null when the pass's paint continuation
+        // failed (e.g. a transform chunk threw): the frame is skipped,
+        // but that must not kill the render loop with an NPE here.
+        if (mouseInThisEye && pending.segmentContexts != null) {
+            combineMouseResults(pending.segmentContexts, pending.context);
+            viewRepaintNeeded = pending.context.handlePossibleComponentMouseEvent();
+        }
+
+        if (developerTools.showSegmentBoundaries) {
+            final RenderingContext context = pending.context;
+            final int tilesX = context.tilesX;
+            final int tilesY = context.tilesY;
+            final int tileH = context.height / tilesY;
+            final int tileW = pending.eyeWidth / tilesX;
+            final int[] pixels = context.pixels;
+            final int width = context.width;
+            final int height = context.height;
+            final int red = (255 << 16);
+            for (int ty = 1; ty < tilesY; ty++) {
+                final int offset = ty * tileH * width;
+                Arrays.fill(pixels, offset + pending.eyeOffsetX,
+                        offset + pending.eyeOffsetX + pending.eyeWidth, red);
+            }
+            for (int tx = 1; tx < tilesX; tx++) {
+                final int x = pending.eyeOffsetX + tx * tileW;
+                for (int y = 0; y < height; y++)
+                    pixels[y * width + x] = red;
+            }
+        }
+
+        if (pending.lastPassOfFrame) {
+            // Hand the completed frame to the present thread (mailbox:
+            // only the newest is shown). The render thread never blocks
+            // on the display; workers stay fed from the queue meanwhile.
+            presentFrame(pending.context, pending.frameGate);
+        }
+    }
+
+    /**
+     * Flushes every pending paint that is already complete, plus — when
+     * correctness demands — awaits older passes. Transform of pass P may
+     * only start once paint(P-3) is complete (vertex slot and aggregator
+     * cycle of 3); callers pass P-3 as {@code maxPassIndex}. Passes newer
+     * than that are flushed without blocking when their latch already
+     * reached zero, so completed frames are blitted as early as possible.
+     *
+     * @param maxPassIndex passes up to this index MUST be complete on return
+     */
+    private void flushCompletedPasses(final long maxPassIndex) {
+        while (true) {
+            final PendingPaint head = pendingPaints.peek();
+            if (head == null)
+                return;
+            if (head.passIndex > maxPassIndex
+                    && (head.ready.getCount() > 0
+                        || head.latch == null
+                        || head.latch.getCount() > 0))
+                return; // still being prepared or painted; leave it queued
+            flushPendingPaint();
+        }
+    }
+
+    /**
+     * Clears a single tile's pixel area to the background color.
+     * Called by each render thread before painting shapes.
+     * The tile context carries exact X and Y bounds (X bounds also cover
+     * the stereo per-eye viewport).
+     *
+     * @param ctx the tile rendering context with X/Y bounds
+     */
+    private void clearSegmentPixels(final SegmentRenderingContext ctx) {
+        final int rgb = (backgroundColor.r << 16) | (backgroundColor.g << 8) | backgroundColor.b;
+        final int width = ctx.width;
+        final int[] pixels = ctx.pixels;
+
+        final int minX = ctx.renderMinX;
+        final int maxX = ctx.renderMaxX;
+
+        final float[] depth = ctx.depth;
+        for (int y = ctx.renderMinY; y < ctx.renderMaxY; y++) {
+            final int rowOffset = y * width;
+            Arrays.fill(pixels, rowOffset + minX, rowOffset + maxX, rgb);
+            Arrays.fill(depth, rowOffset + minX, rowOffset + maxX,
+                    Float.NEGATIVE_INFINITY);
+        }
+    }
+
+    private void combineMouseResults(final SegmentRenderingContext[] segmentContexts,
+                                     final RenderingContext context) {
+        // All segments paint shapes back-to-front, and mouse hit detection
+        // happens before Y-bound clipping. So each segment should report the
+        // same "last hit" (frontmost shape under mouse). Just take the first non-null.
+        for (final SegmentRenderingContext ctx : segmentContexts) {
+            final MouseInteractionController hit = ctx.getSegmentMouseHit();
+            if (hit != null) {
+                context.setCurrentObjectUnderMouseCursor(hit,
+                        ctx.getSegmentMouseHitU(), ctx.getSegmentMouseHitV());
+                return;
+            }
+        }
+    }
+
+    /**
+     * Calling these methods tells 3D engine that current 3D view needs to be
+     * repainted on first opportunity.
+     */
+    public void repaintDuringNextViewUpdate() {
+        viewRepaintNeeded = true;
+    }
+
+    /**
+     * Set target frames per second rate for this view. Target FPS can be changed at runtime.
+     * Use 0 or negative value for unlimited FPS (max performance mode for benchmarking).
+     *
+     * @param frameRate target frames per second rate for this view.
+     */
+    public void setFrameRate(final int frameRate) {
+        targetFPS = frameRate;
+    }
+
+    /**
+     * Returns the current target frames per second rate.
+     *
+     * @return target FPS; 0 or less means unlimited
+     */
+    public int getTargetFPS() {
+        return targetFPS;
+    }
+
+    /**
+     * Returns the measured production rate: frames completed per second,
+     * averaged over the last ~500 ms window. This is the benchmark number:
+     * how fast the pipeline produces frames, regardless of how quickly
+     * the display path presents them (the mailbox present thread may
+     * drop stale frames when the display is slower than production).
+     *
+     * @return measured frames per second
+     */
+    public double getMeasuredFPS() {
+        return measuredFPS;
+    }
+
+    /**
+     * Counts one blitted frame into the FPS measurement window.
+     * Called by the render thread after each completed blit.
+     */
+    private void noteFrameBlitted() {
+        fpsWindowFrames++;
+        final long now = System.nanoTime();
+        if (fpsWindowStartNanos == 0) {
+            fpsWindowStartNanos = now;
+            fpsWindowFrames = 0;
+            return;
+        }
+        final long elapsed = now - fpsWindowStartNanos;
+        if (elapsed >= 500_000_000L) {
+            measuredFPS = fpsWindowFrames * 1e9 / elapsed;
+            fpsWindowStartNanos = now;
+            fpsWindowFrames = 0;
+        }
+    }
+
+    /**
+     * Returns the current number of render threads.
+     *
+     * @return the number of render threads
+     */
+    public int getNumRenderThreads() {
+        return numRenderThreads;
+    }
+
+    /**
+     * Sets the number of render threads. Takes effect on the next frame.
+     * The executor service is recreated lazily when the render loop detects the change.
+     *
+     * @param count number of render threads (must be at least 1)
+     */
+    public void setNumRenderThreads(final int count) {
+        if (count < 1)
+            throw new IllegalArgumentException("Render thread count must be at least 1, got: " + count);
+        numRenderThreads = count;
+        viewRepaintNeeded = true;
+    }
+
+    // ------------------------------------------------------------------
+    // Stereo rendering
+    // ------------------------------------------------------------------
+
+    /**
+     * Default inter-pupillary distance in world units (centimeters).
+     * Human IPD ranges from ~5.5 to ~7.5 cm; 6.5 cm is the population median.
+     */
+    private static final double DEFAULT_STEREO_IPD = 6.5;
+
+    /** Inter-pupillary distance in world units, configurable at runtime. */
+    private double stereoIPD = EngineConfig.getIpdCm();
+
+    /** Whether side-by-side stereoscopic rendering is enabled. */
+    private boolean stereoModeEnabled = false;
+
+    /**
+     * Returns whether side-by-side stereoscopic rendering is currently enabled.
+     *
+     * @return {@code true} if stereo mode is active
+     */
+    public boolean isStereoModeEnabled() {
+        return stereoModeEnabled;
+    }
+
+    /**
+     * Enables or disables side-by-side stereoscopic rendering.
+     * When enabled, each frame renders two eye views side-by-side.
+     *
+     * @param enabled {@code true} to enable stereo mode, {@code false} to disable
+     */
+    public void setStereoModeEnabled(final boolean enabled) {
+        this.stereoModeEnabled = enabled;
+        viewRepaintNeeded = true;
+    }
+
+    /**
+     * Returns the current inter-pupillary distance used for stereo rendering.
+     *
+     * @return IPD in world units (centimeters)
+     */
+    public double getStereoIPD() {
+        return stereoIPD;
+    }
+
+    /**
+     * Sets the inter-pupillary distance for stereo rendering.
+     * Human IPD ranges from ~5.5 to ~7.5 cm; for XR glasses the optical
+     * IPD may differ from the user's anatomical IPD.
+     *
+     * @param ipd the inter-pupillary distance in world units (centimeters)
+     */
+    public void setStereoIPD(final double ipd) {
+        this.stereoIPD = ipd;
+        viewRepaintNeeded = true;
+    }
+
+    /**
+     * Runs the transform phase of a single eye pass: offsets the camera for
+     * the eye, updates the frame context viewport fields, transforms, sorts
+     * and tile-bins the scene. The pass's projection slot is
+     * {@code passCounter & 1}; its paint (submitted later by
+     * {@link #submitPaintPass}) reads the same slot from copies taken while
+     * it is still current.
+     *
+     * @param eye        which eye to render
+     * @param eyeWidth   width of the eye viewport in pixels
+     * @param eyeOffsetX X offset of the eye viewport within the full buffer
+     */
+    private RenderingContext transformPass(final StereoEye eye, final int eyeWidth, final int eyeOffsetX) {
+        final boolean trace = ThreadActivityRecorder.isEnabled();
+        final long t0 = trace ? System.nanoTime() : 0;
+        final Camera camera = getCamera();
+        final Point3D location = camera.getTransform().getTranslation();
+
+        final double originalX = location.x;
+        if (eye != StereoEye.NONE) {
+            final double ipdOffset = (eye == StereoEye.LEFT) ? -stereoIPD / 2.0 : stereoIPD / 2.0;
+            location.x += ipdOffset;
+        }
+
+        try {
+            // Independent per-pass context: the walk, its forked chunk
+            // tasks and the asynchronous sort/bin/paint continuation all
+            // read this copy, so the NEXT pass's setup (a new copy)
+            // cannot disturb work that is still in flight.
+            final RenderingContext passContext = new RenderingContext(renderingContext);
+            passContext.stereoEye = eye;
+            passContext.stereoViewportWidth = eyeWidth;
+            passContext.stereoViewportOffsetX = eyeOffsetX;
+            passContext.renderMinX = eyeOffsetX;
+            passContext.renderMaxX = eyeOffsetX + eyeWidth;
+            passContext.centerCoordinate.x = eyeWidth / 2.0;
+            passContext.projectionScale = eyeWidth / 3.0;
+            passContext.vertexSlot = (int) (passCounter % 3);
+
+            // Walks the tree and forks heavy composites into chunk tasks,
+            // but does NOT wait for them: draining happens inside the
+            // pass's continuation on a worker thread.
+            rootShapeCollection.transformShapesBegin(this, passContext);
+            return passContext;
+        } finally {
+            location.x = originalX;
+            if (trace) {
+                ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_RENDER, t0, System.nanoTime());
+            }
+        }
+    }
+
+    /**
+     * Submits the paint phase of a single eye pass to the render executor
+     * and returns immediately (unless the pipeline kill switch is off).
+     * Tiles are work-stolen off a shared ticket; output is unaffected —
+     * every tile is still painted by exactly one thread, in fixed
+     * (Z, shapeId) order.
+     *
+     * @param eye             which eye this pass renders
+     * @param eyeWidth        width of the eye viewport in pixels
+     * @param eyeOffsetX      X offset of the eye viewport within the buffer
+     * @param lastPassOfFrame true when this pass completes its frame
+     */
+    private void submitPaintPass(final StereoEye eye, final int eyeWidth, final int eyeOffsetX,
+                                 final boolean lastPassOfFrame, final RenderingContext passContext,
+                                 final java.util.concurrent.CountDownLatch previousGate,
+                                 final java.util.concurrent.CountDownLatch frameGate) {
+        final RenderingContext frameContext = renderingContext;
+        final int tilesX = frameContext.tilesX;
+        final int tilesY = frameContext.tilesY;
+        final int height = frameContext.height;
+        final int slot = passContext.vertexSlot;
+        final ExecutorService executor = getOrCreateTransformExecutor();
+
+        // Enqueue the pending-paint shell synchronously so pendingPaints
+        // stays in pass order; the continuation fills in the rest.
+        final PendingPaint pending = new PendingPaint();
+        pending.context = frameContext;
+        pending.eyeOffsetX = eyeOffsetX;
+        pending.eyeWidth = eyeWidth;
+        pending.lastPassOfFrame = lastPassOfFrame;
+        pending.passIndex = passCounter;
+        pending.frameGate = frameGate;
+        pendingPaints.addLast(pending);
+
+        passCounter++;
+
+        final int tracePaintKind = ThreadActivityRecorder.KIND_PAINT + ThreadActivityRecorder.frameParity();
+
+        // The whole rest of the pass is one asynchronous continuation on
+        // the shared executor: drain the transform chunks (a ForkJoinTask
+        // get() here work-steals instead of blocking), depth-sort on the
+        // same pool, bin per tile, then submit the paint ticket tasks.
+        // The render thread never waits for any of it.
+        executor.submit(() -> {
+            final boolean trace = ThreadActivityRecorder.isEnabled();
+            final long t0 = trace ? System.nanoTime() : 0;
+            try {
+                long ts = trace ? System.nanoTime() : 0;
+                rootShapeCollection.drainTransformShapes(passContext);
+                if (trace) {
+                    ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_DRAIN, ts, System.nanoTime());
+                    ts = System.nanoTime();
+                }
+                // Present gate: never overwrite a buffer the present
+                // thread is still blitting from (frame F-3's contents).
+                // Fires instantly unless the display is >=3 frames behind.
+                // The timeout is insurance, not control flow: a present
+                // path failure must degrade to a torn frame, never to a
+                // frozen pipeline.
+                if (!previousGate.await(2, java.util.concurrent.TimeUnit.SECONDS)) {
+                    debugLogBuffer.log("[VIEWPANEL] present gate timeout — presenting may be stuck");
+                }
+                if (trace) {
+                    ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_AWAIT, ts, System.nanoTime());
+                }
+                ts = trace ? System.nanoTime() : 0;
+                rootShapeCollection.sortShapes(slot, executor);
+                if (trace) {
+                    ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_SORT, ts, System.nanoTime());
+                }
+                rootShapeCollection.binShapesForTiles(slot, tilesX, tilesY,
+                        eyeOffsetX, eyeWidth, height, executor);
+
+                final int tileW = eyeWidth / tilesX;
+                final int tileH = height / tilesY;
+                final int segments = tilesX * tilesY;
+                // In stereo the right eye's segment indices follow the left eye's
+                final int eyeBase = (eye == StereoEye.RIGHT) ? segments : 0;
+
+                final SegmentRenderingContext[] segmentContexts = new SegmentRenderingContext[segments];
+                for (int ty = 0; ty < tilesY; ty++) {
+                    final int minY = ty * tileH;
+                    final int maxY = (ty == tilesY - 1) ? height : (ty + 1) * tileH;
+                    for (int tx = 0; tx < tilesX; tx++) {
+                        final int minX = eyeOffsetX + tx * tileW;
+                        final int maxX = (tx == tilesX - 1) ? eyeOffsetX + eyeWidth : minX + tileW;
+                        final int index = ty * tilesX + tx;
+                        final SegmentRenderingContext tileContext = new SegmentRenderingContext(
+                                passContext, minY, maxY, eyeBase + index);
+                        tileContext.vertexSlot = slot;
+                        tileContext.renderMinX = minX;
+                        tileContext.renderMaxX = maxX;
+                        segmentContexts[index] = tileContext;
+                    }
+                }
+
+                // Per-tile paint tasks on the fork/join pool: a worker
+                // finishing one tile immediately pulls ANY next queued
+                // work — another tile (of this or an adjacent frame's
+                // pass), a transform chunk, a continuation — so cores
+                // never idle waiting for a ticket-loop task to end.
+                final CountDownLatch paintLatch = new CountDownLatch(segments);
+
+                for (int s = 0; s < segments; s++) {
+                    final int segmentIndex = s;
+                    if (developerTools.renderAlternateSegments && (segmentIndex % 2 == 1)) {
+                        paintLatch.countDown();
+                        continue;
+                    }
+                    executor.submit(() -> {
+                        final long pt0 = trace ? System.nanoTime() : 0;
+                        try {
+                            clearSegmentPixels(segmentContexts[segmentIndex]);
+                            rootShapeCollection.paintShapes(segmentContexts[segmentIndex]);
+                        } finally {
+                            if (trace) {
+                                ThreadActivityRecorder.record(tracePaintKind, pt0, System.nanoTime());
+                            }
+                            paintLatch.countDown();
+                        }
+                    });
+                }
+
+                pending.segmentContexts = segmentContexts;
+                pending.latch = paintLatch;
+            } catch (final Throwable t) {
+                debugLogBuffer.log("[VIEWPANEL] paint continuation failed: " + t);
+                t.printStackTrace();
+            } finally {
+                if (trace) {
+                    ThreadActivityRecorder.record(ThreadActivityRecorder.KIND_PREP, t0, System.nanoTime());
+                }
+                pending.ready.countDown();
+            }
+        });
+
+        if (!pipelineEnabled) {
+            flushCompletedPasses(Long.MAX_VALUE);
+        }
+    }
+
+    // ------------------------------------------------------------------
+
+    /**
+     * Default number of paint segment threads: 75% of available CPU
+     * threads, clamped to at least 1 and at most (CPU threads - 1), so
+     * one thread always stays free for the rest of the system.
+     *
+     * @return the default render thread count
+     */
+    private static int defaultRenderThreadCount() {
+        final int cores = Runtime.getRuntime().availableProcessors();
+        return Math.max(1, Math.min((int) Math.round(cores * 0.75), cores - 1));
+    }
+
+    /**
+     * Returns the shared transform executor, creating it on first use.
+     * Called from the render thread only (no synchronization needed).
+     *
+     * @return the transform executor
+     */
+    private ExecutorService getOrCreateTransformExecutor() {
+        if (transformExecutor == null || transformExecutor.isShutdown()) {
+            final int threads = numRenderThreads;
+            final AtomicInteger workerCounter = new AtomicInteger();
+            // ForkJoinPool, not a fixed thread pool: the instrumented
+            // parallel merge sort and bin/merge copy tasks fork onto THIS
+            // pool (never the common pool), and a worker blocked in
+            // ForkJoinTask.get() (continuation draining transform chunks)
+            // work-steals other tasks instead of idling. asyncMode = FIFO
+            // submission queues, so older frames' work is preferred.
+            // Sized to the ALLOCATED thread count (75% of cores), not all
+            // cores: measured 2026-09-05 (UtilizationBench) that 24/24
+            // threads run only ~70% busy — GC/JIT/OS threads displace
+            // workers and stretch frame tails — while 18/18 stay ~83%+
+            // busy AND deliver higher FPS.
+            transformExecutor = new java.util.concurrent.ForkJoinPool(threads,
+                    pool -> {
+                        final java.util.concurrent.ForkJoinWorkerThread thread =
+                                java.util.concurrent.ForkJoinPool.defaultForkJoinWorkerThreadFactory
+                                        .newThread(pool);
+                        thread.setName("e3d-worker-" + workerCounter.getAndIncrement());
+                        thread.setDaemon(true);
+                        return thread;
+                    }, null, true);
+        }
+        return transformExecutor;
+    }
+
+    /**
+     * Recreates the executor service if the thread count has changed since last creation.
+     * Called from the render thread only (no synchronization needed).
+     */
+    private void ensureExecutorMatchesThreadCount() {
+        if (renderExecutor == null || renderExecutor.isShutdown()) {
+            renderExecutor = Executors.newFixedThreadPool(numRenderThreads);
+            return;
+        }
+        if (renderExecutor instanceof java.util.concurrent.ThreadPoolExecutor) {
+            final java.util.concurrent.ThreadPoolExecutor tpe = (java.util.concurrent.ThreadPoolExecutor) renderExecutor;
+            if (tpe.getCorePoolSize() != numRenderThreads) {
+                tpe.shutdown();
+                renderExecutor = Executors.newFixedThreadPool(numRenderThreads);
+            }
+        }
+    }
+
+    /**
+     * Starts the head tracking hot-plug manager: RayNeo glasses are
+     * detected when plugged in (even after startup) and head look-around
+     * is enabled automatically. Disable with {@code -De3d.headtrack=false}.
+     */
+    private void initializeHeadTracking() {
+        if ("false".equalsIgnoreCase(
+                System.getProperty("e3d.headtrack", "true")))
+            return;
+        headTrackingManager = new HeadTrackingManager(this);
+        headTrackingManager.start();
+    }
+
+    /**
+     * Starts the SpaceNavigator hot-plug manager: the 6DOF mouse is
+     * detected when plugged in (even after startup) and cap deflection
+     * drives the camera automatically. Disable with
+     * {@code -De3d.spacemouse=false}.
+     */
+    private void initializeSpaceMouse() {
+        if ("false".equalsIgnoreCase(
+                System.getProperty("e3d.spacemouse", "true")))
+            return;
+        spaceMouseManager = new SpaceMouseManager(this);
+        spaceMouseManager.start();
+    }
+
+    /**
+     * Returns the active SpaceNavigator device, or null when no 6DOF
+     * mouse is currently connected.
+     */
+    public SpaceNavigatorHid getSpaceMouse() {
+        return spaceMouseManager == null ? null
+                : spaceMouseManager.getDevice();
+    }
+
+    /**
+     * Returns the active head tracker, or null when no glasses are
+     * currently connected.
+     */
+    public HeadTracker getHeadTracker() {
+        return headTrackingManager == null ? null
+                : headTrackingManager.getTracker();
+    }
+
+    /**
+     * Stops rendering of this view.
+     */
+    public void stop() {
+        if (headTrackingManager != null) {
+            headTrackingManager.stop();
+            headTrackingManager = null;
+        }
+        if (spaceMouseManager != null) {
+            spaceMouseManager.stop();
+            spaceMouseManager = null;
+        }
+        renderThreadRunning = false;
+        presentThreadRunning = false;
+        presentSignal.release();
+        final PresentJob dropped = mailboxFrame.getAndSet(null);
+        if (dropped != null) {
+            dropped.gate.countDown();
+        }
+        pendingPaints.clear();
+        renderExecutor.shutdownNow();
+        if (transformExecutor != null) {
+            transformExecutor.shutdownNow();
+        }
+        if (renderThread != null) {
+            try {
+                renderThread.join();
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            renderThread = null;
+        }
+    }
+
+    /**
+     * Starts the render thread that continuously generates frames.
+     */
+    private synchronized void startRenderThread() {
+        if (renderThread != null)
+            return;
+
+        renderThreadRunning = true;
+        renderThread = new Thread(this::renderLoop, "e3d-render");
+        renderThread.setDaemon(true);
+        renderThread.start();
+
+        if (!presentThreadRunning) {
+            presentThreadRunning = true;
+            presentThread = new Thread(this::presentLoop, "e3d-present");
+            presentThread.setDaemon(true);
+            presentThread.start();
+        }
+    }
+
+    /**
+     * Main render loop that generates frames continuously.
+     * Supports both unlimited FPS and fixed FPS modes with dynamic sleep adjustment.
+     */
+    private void renderLoop() {
+        nextFrameTime = System.currentTimeMillis();
+
+        while (renderThreadRunning) {
+            try {
+                ensureThatViewIsUpToDate();
+            } catch (final Exception e) {
+                e.printStackTrace();
+            }
+
+            if (maintainTargetFps()) break;
+        }
+    }
+
+    /**
+     * Ensures that the rendering process maintains the target frames per second (FPS)
+     * by dynamically adjusting the thread sleep duration.
+     *
+     * @return {@code true} if the thread was interrupted while sleeping, otherwise {@code false}.
+     */
+    private boolean maintainTargetFps() {
+        if (targetFPS <= 0) return false;
+
+        long now = System.currentTimeMillis();
+
+        nextFrameTime += 1000L / targetFPS;
+
+        // If we've fallen behind, reset to now instead of trying to catch up
+        if (nextFrameTime < now)
+            nextFrameTime = now;
+
+        long sleepTime = nextFrameTime - now;
+        if (sleepTime > 0) {
+            try {
+                Thread.sleep(sleepTime);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * This method is executed by periodic timer task, in frequency according to
+     * defined frame rate.
+     * <p>
+     * It tells view to update itself. View can decide if actual re-rendering of
+     * graphics is needed.
+     */
+    void ensureThatViewIsUpToDate() {
+        maintainRenderingContext();
+
+        final int millisecondsPassedSinceLastUpdate = getMillisecondsPassedSinceLastUpdate();
+
+        boolean renderFrame = notifyFrameListeners(millisecondsPassedSinceLastUpdate);
+
+        // Unlimited FPS is benchmark mode: render continuously even when
+        // the scene reports no changes, so the measured rate reflects
+        // maximum achievable throughput.
+        if (targetFPS <= 0) {
+            renderFrame = true;
+        }
+
+        if (viewRepaintNeeded) {
+            viewRepaintNeeded = false;
+            renderFrame = true;
+        }
+
+        // abort rendering if window size is invalid
+        if ((getWidth() > 0) && (getHeight() > 0) && renderFrame) {
+            renderFrame();
+        } else {
+            // No new frame: make sure the last submitted paint passes
+            // still get awaited, mouse-processed and blitted.
+            flushCompletedPasses(Long.MAX_VALUE);
+        }
+    }
+
+    private void maintainRenderingContext() {
+        int panelWidth = getWidth();
+        int panelHeight = getHeight();
+
+        if (panelWidth <= 0 || panelHeight <= 0) {
+            if (frameContexts[0] != null) {
+                frameContexts[0].dispose();
+                frameContexts[1].dispose();
+                frameContexts[2].dispose();
+                frameContexts[0] = null;
+                frameContexts[1] = null;
+                frameContexts[2] = null;
+                renderingContext = null;
+                pendingPaints.clear();
+            }
+            return;
+        }
+
+        // create new rendering contexts if window size has changed OR the
+        // tile grid has changed (thread count, stereo toggle)
+        final int viewportCount = stereoModeEnabled ? 2 : 1;
+        final int viewportWidth = panelWidth / viewportCount;
+        // Total tiles ~= 10x paint threads (work-stealing granularity);
+        // split into roughly square tiles: squares minimize boundary
+        // crossings, i.e. how many tiles each shape overlaps
+        final int targetTiles = Math.max(1, numRenderThreads * TILES_PER_THREAD);
+        final int tilesX = Math.max(1, Math.min(viewportWidth, (int) Math.round(
+                Math.sqrt(targetTiles * (double) viewportWidth / panelHeight))));
+        final int tilesY = Math.max(1, Math.min(panelHeight,
+                (int) Math.round((double) targetTiles / tilesX)));
+        if ((frameContexts[0] == null)
+                || (frameContexts[0].width != panelWidth)
+                || (frameContexts[0].height != panelHeight)
+                || (frameContexts[0].tilesX != tilesX)
+                || (frameContexts[0].tilesY != tilesY)
+                || (frameContexts[0].viewportCount != viewportCount)) {
+            // The pending paints still work on the old buffers; let them
+            // finish before disposing the contexts they write into.
+            flushCompletedPasses(Long.MAX_VALUE);
+            final PresentJob droppedJob = mailboxFrame.getAndSet(null);
+            if (droppedJob != null) {
+                droppedJob.gate.countDown();
+            }
+            for (int parity = 0; parity < 3; parity++) {
+                if (frameContexts[parity] != null) {
+                    frameContexts[parity].dispose();
+                }
+                final RenderingContext context = new RenderingContext(panelWidth, panelHeight,
+                        tilesX, tilesY, viewportCount);
+                context.developerTools = developerTools;
+                context.debugLogBuffer = debugLogBuffer;
+                context.lightingManager = lightingManager;
+                frameContexts[parity] = context;
+            }
+        }
+
+        renderingContext = frameContexts[frameParity];
+        renderingContext.transformExecutor = getOrCreateTransformExecutor();
+        renderingContext.prepareForNewFrameRendering();
+    }
+
+    private boolean notifyFrameListeners(int millisecondsPassedSinceLastUpdate) {
+        boolean reRenderFrame = false;
+        for (final FrameListener listener : frameListeners)
+            if (listener.onFrame(this, millisecondsPassedSinceLastUpdate))
+                reRenderFrame = true;
+        return reRenderFrame;
+    }
+
+    private int getMillisecondsPassedSinceLastUpdate() {
+        final long currentTime = System.currentTimeMillis();
+
+        if (lastUpdateMillis == 0)
+            lastUpdateMillis = currentTime;
+
+        final int millisecondsPassedSinceLastUpdate = (int) (currentTime - lastUpdateMillis);
+        lastUpdateMillis = currentTime;
+        return millisecondsPassedSinceLastUpdate;
+    }
+
+    /**
+     * Removes a previously registered frame listener.
+     *
+     * @param frameListener the listener to remove
+     */
+    public void removeFrameListener(FrameListener frameListener) {
+        frameListeners.remove(frameListener);
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewSpaceTracker.java
new file mode 100644 (file)
index 0000000..40f602a
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+
+/**
+ * Tracks an object's position in view/camera space for distance and angle calculations.
+ *
+ * <p>Used primarily for level-of-detail (LOD) decisions based on how far and at what
+ * angle the viewer is from an object. The tracker maintains the object's center point
+ * transformed into view space, and optionally orientation axes for angle calculations.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ */
+public class ViewSpaceTracker {
+
+    /**
+     * The object's center point (0,0,0 in object space) transformed to view space.
+     */
+    public Vertex center = new Vertex();
+
+    /**
+     * Point at (10,0,0) in object space, used for XZ angle calculation.
+     * Only initialized if orientation tracking is enabled.
+     */
+    public Vertex right;
+
+    /**
+     * Point at (0,10,0) in object space, used for YZ angle calculation.
+     * Only initialized if orientation tracking is enabled.
+     */
+    public Vertex down;
+
+    /**
+     * Creates a new view space tracker.
+     */
+    public ViewSpaceTracker() {
+    }
+
+    /**
+     * Transforms the tracked points from object space to view space.
+     *
+     * @param transformPipe     the current transform stack
+     * @param renderingContext  the rendering context for frame info
+     */
+    public void analyze(final TransformStack transformPipe,
+                        final RenderingContext renderingContext) {
+
+        center.calculateLocationRelativeToViewer(transformPipe, renderingContext);
+
+        if (right != null) {
+            right.calculateLocationRelativeToViewer(transformPipe, renderingContext);
+            down.calculateLocationRelativeToViewer(transformPipe, renderingContext);
+        }
+    }
+
+    /**
+     * Enables tracking of orientation axes for angle calculations.
+     * Disabled by default to save computation when angles are not needed.
+     */
+    public void enableOrientationTracking() {
+        right = new Vertex(new Point3D(10, 0, 0));
+        down = new Vertex(new Point3D(0, 10, 0));
+    }
+
+    /**
+     * Returns the angle between the viewer and object in the XY plane.
+     *
+     * @return the XY angle in radians
+     */
+    public double getAngleXY(final RenderingContext renderingContext) {
+        return center.transformedCoordinate(renderingContext)
+                .getAngleXY(down.transformedCoordinate(renderingContext));
+    }
+
+    /**
+     * Returns the angle between the viewer and object in the XZ plane.
+     *
+     * @return the XZ angle in radians
+     */
+    public double getAngleXZ(final RenderingContext renderingContext) {
+        return center.transformedCoordinate(renderingContext)
+                .getAngleXZ(right.transformedCoordinate(renderingContext));
+    }
+
+    /**
+     * Returns the angle between the viewer and object in the YZ plane.
+     *
+     * @return the YZ angle in radians
+     */
+    public double getAngleYZ(final RenderingContext renderingContext) {
+        return center.transformedCoordinate(renderingContext)
+                .getAngleYZ(down.transformedCoordinate(renderingContext));
+    }
+
+    /**
+     * Returns the distance from the camera to the object's center.
+     * Used for level-of-detail calculations.
+     *
+     * @return the distance in world units
+     */
+    public double getDistanceToCamera(final RenderingContext renderingContext) {
+        return center.transformedCoordinate(renderingContext).getVectorLength();
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewUpdateTimerTask.java
new file mode 100755 (executable)
index 0000000..061880c
--- /dev/null
@@ -0,0 +1,31 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui;
+
+/**
+ * Timer task that updates view.
+ *
+ * Tries to keep constant FPS.
+ */
+public class ViewUpdateTimerTask extends java.util.TimerTask {
+
+    /** The view panel to update. */
+    public ViewPanel viewPanel;
+
+    /**
+     * Creates a new timer task for the given view panel.
+     *
+     * @param viewPanel the view panel to update
+     */
+    public ViewUpdateTimerTask(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+    }
+
+    @Override
+    public void run() {
+        viewPanel.ensureThatViewIsUpToDate();
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadLookController.java
new file mode 100644 (file)
index 0000000..dab2fc8
--- /dev/null
@@ -0,0 +1,144 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.headtrack;
+
+import eu.svjatoslav.aukio.e3d.gui.FrameListener;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * Applies head orientation from XR glasses to the camera, every frame:
+ * turn your head left and the view pans left, look up and the view
+ * tilts up. The virtual world stays fixed in space while the display
+ * moves with your head.
+ *
+ * <p><b>Complementary mouse look:</b> mouse drag and head tracking
+ * compose instead of fighting. Every frame the controller compares the
+ * camera rotation against what it wrote last frame; a mismatch means
+ * the mouse moved the view, and the difference is folded into the base
+ * yaw/pitch so the view stays exactly where the mouse put it —
+ * subsequent head motion then composes on top. Head movement during a
+ * drag is not lost: it re-applies as a relative delta once the drag
+ * ends.</p>
+ *
+ * <p>Press <b>Scroll Lock</b> to recenter: the current head pose becomes
+ * "straight ahead" and the camera returns to its base pose. Needed
+ * occasionally because gyro-only yaw drifts slowly.</p>
+ *
+ * <p>Arrow-key movement and wheel vertical movement are unaffected
+ * (they translate, not rotate).</p>
+ *
+ * <p>Head roll is measured but deliberately not applied — a constant
+ * world horizon reads better than a world that tilts with your neck.</p>
+ */
+public final class HeadLookController implements FrameListener {
+
+    /**
+     * Sign mapping, confirmed by live user testing: positive engine yaw
+     * turns the view left, so head-yaw is added directly; head-down
+     * integrates to positive pitch while negative engine pitch looks
+     * down, so pitch is subtracted.
+     */
+    private static final double YAW_SIGN = 1.0;
+    private static final double PITCH_SIGN = -1.0;
+
+    /** Yaw sensitivity multiplier; 2.0 = the view turns twice as far as
+     * the head (live-tuned 2026-09-10: head yaw was under-responsive). */
+    private static final double YAW_GAIN = 2.0;
+
+    /** Pitch sensitivity multiplier; 1.0 = the world stays fixed in space. */
+    private static final double PITCH_GAIN = 1.0;
+
+    /**
+     * Two rotations are "the same" below this per-component distance.
+     * A rotation this controller wrote itself compares bitwise identical;
+     * anything past this epsilon came from another writer (mouse drag).
+     */
+    private static final double SAME_ROTATION_EPSILON = 1e-9;
+
+    private final HeadTracker tracker;
+    private final ViewPanel viewPanel;
+
+    private double baseYaw, basePitch;
+
+    /** Rotation this controller wrote to the camera last frame. */
+    private Quaternion lastApplied;
+
+    private boolean recenteredOnce;
+    private boolean scrollLockWasPressed;
+
+    public HeadLookController(final HeadTracker tracker,
+                              final ViewPanel viewPanel) {
+        this.tracker = tracker;
+        this.viewPanel = viewPanel;
+    }
+
+    @Override
+    public boolean onFrame(final ViewPanel viewPanel,
+                           final int millisecondsSinceLastFrame) {
+        if (!tracker.isCalibrated())
+            return false;
+
+        final boolean scrollLockPressed = viewPanel.getInputManager()
+                .isKeyPressed(KeyEvent.VK_SCROLL_LOCK);
+        if (scrollLockPressed && !scrollLockWasPressed) {
+            captureBasePose();
+            tracker.recenter();
+            recenteredOnce = true;
+            lastApplied = null;
+        }
+        scrollLockWasPressed = scrollLockPressed;
+
+        if (!recenteredOnce) {
+            // Boot recenter: capture the camera pose the application
+            // configured (not the constructor-time identity) and make
+            // the current head pose "straight ahead".
+            captureBasePose();
+            tracker.recenter();
+            recenteredOnce = true;
+        }
+
+        final double lookYaw = tracker.getLookYaw();
+        final double lookPitch = tracker.getLookPitch();
+
+        if (lastApplied != null) {
+            final Quaternion current = viewPanel.getCamera()
+                    .getTransform().getRotation();
+            if (!nearlyEqual(current, lastApplied)) {
+                // An external writer (mouse drag) moved the camera.
+                // Re-derive base yaw/pitch so the view stays exactly
+                // where the mouse left it — no snap-back.
+                final double[] angles = current.toAngles();
+                baseYaw = angles[0] - YAW_SIGN * YAW_GAIN * lookYaw;
+                basePitch = angles[1] - PITCH_SIGN * PITCH_GAIN * lookPitch;
+            }
+        }
+
+        final double yaw = baseYaw + YAW_SIGN * YAW_GAIN * lookYaw;
+        final double pitch = basePitch + PITCH_SIGN * PITCH_GAIN * lookPitch;
+        final Quaternion applied = Quaternion.fromAngles(yaw, pitch, 0);
+        viewPanel.getCamera().getTransform().getRotation().set(applied);
+        lastApplied = applied;
+
+        return true;
+    }
+
+    private void captureBasePose() {
+        final double[] angles = viewPanel.getCamera().getTransform()
+                .getRotation().toAngles();
+        baseYaw = angles[0];
+        basePitch = angles[1];
+    }
+
+    private static boolean nearlyEqual(final Quaternion a,
+                                       final Quaternion b) {
+        return Math.abs(a.w - b.w) < SAME_ROTATION_EPSILON
+                && Math.abs(a.x - b.x) < SAME_ROTATION_EPSILON
+                && Math.abs(a.y - b.y) < SAME_ROTATION_EPSILON
+                && Math.abs(a.z - b.z) < SAME_ROTATION_EPSILON;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTracker.java
new file mode 100644 (file)
index 0000000..7187850
--- /dev/null
@@ -0,0 +1,306 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.headtrack;
+
+import com.sun.jna.Memory;
+
+/**
+ * Head orientation tracker built on the RayNeo glasses IMU.
+ *
+ * <p>A daemon thread reads 500 Hz IMU frames and fuses them into yaw,
+ * pitch and roll angles with a complementary filter: gyroscope
+ * integration for responsiveness, accelerometer gravity as the long-term
+ * pitch/roll reference. Yaw has no absolute reference (the magnetometer
+ * is too noisy indoors near a laptop), so it drifts slowly — call
+ * {@link #recenter()} (Scroll Lock in {@link HeadLookController})
+ * whenever the view feels off-center.</p>
+ *
+ * <p>Sensor axes, established by calibration capture: gyro[0]/X = pitch
+ * (nod), gyro[1]/Y = yaw (left/right turn), gyro[2]/Z = roll
+ * (ear-to-shoulder tilt).</p>
+ *
+ * <p>Drift control ("standstill perceived as slow motion"):</p>
+ * <ul>
+ *   <li>Startup calibration only accepts samples taken while the
+ *       glasses are stationary — moving samples would poison the bias
+ *       estimate with phantom rotation. Putting the glasses on a desk
+ *       for the first second gives the cleanest estimate, but a held
+ *       head passes the stillness gate too.</li>
+ *   <li>While running, the bias is continuously re-estimated whenever
+ *       the head is stationary (fast EMA, ~0.5s time constant).</li>
+ *   <li>Residual rates below {@value #DEADBAND_DPS} dps after bias
+ *       subtraction are integrated as exactly zero — sensor noise can
+ *       never accumulate into visible rotation.</li>
+ * </ul>
+ */
+public final class HeadTracker {
+
+    /** Sensor axis indices. */
+    private static final int AXIS_PITCH = 0;
+    private static final int AXIS_YAW = 1;
+    private static final int AXIS_ROLL = 2;
+
+    private static final double DPS_TO_RAD = Math.PI / 180.0;
+
+    /** Stationary samples needed for the initial bias estimate. */
+    private static final int CALIBRATION_SAMPLES = 500;
+
+    /** Give up waiting for stillness after this long and calibrate anyway. */
+    private static final int CALIBRATION_TIMEOUT_SAMPLES = 5000;
+
+    /** Gyro magnitude below which the head counts as stationary (dps). */
+    private static final double STATIONARY_THRESHOLD_DPS = 0.5;
+
+    /**
+     * Rates below this after bias subtraction are treated as zero.
+     * Kills the slow phantom rotation that gyro noise would otherwise
+     * integrate into.
+     */
+    private static final double DEADBAND_DPS = 0.07;
+
+    /** Complementary filter weight: accel correction per sample. */
+    private static final double ACCEL_BLEND = 0.02;
+
+    /** Bias re-estimation speed while stationary (~0.5s time constant). */
+    private static final double BIAS_BLEND = 0.004;
+
+    /** Output smoothing (exponential moving average per sample). */
+    private static final double OUTPUT_SMOOTHING = 0.35;
+
+    private final RayNeoHid device;
+    private final Thread readerThread;
+    private final Memory frame = new Memory(RayNeoHid.FRAME_SIZE);
+
+    private final double[] gyroBias = new double[3];
+    private int calibrationSamples;
+    private int calibrationAttempts;
+
+    private double fusedYaw, fusedPitch, fusedRoll;
+    private double centerYaw, centerPitch;
+    private double smoothYaw, smoothPitch;
+    private int lastTick;
+    private long lastSampleNanos;
+    private boolean hasTick;
+
+    private volatile boolean running = true;
+    private volatile boolean calibrated;
+
+    // diagnostics (all written on the reader thread, read anywhere)
+    private volatile int framesReceived;
+    private volatile int lastTickValue;
+    private volatile double lastDtMillis;
+    private volatile double maxAbsGyroDps;
+
+    public HeadTracker(final RayNeoHid device) {
+        this.device = device;
+        readerThread = new Thread(this::readLoop, "rayneo-head-tracker");
+        readerThread.setDaemon(true);
+    }
+
+    public void start() {
+        device.startImu();
+        readerThread.start();
+        final Thread watchdog = new Thread(this::watchdogLoop,
+                "rayneo-imu-watchdog");
+        watchdog.setDaemon(true);
+        watchdog.start();
+    }
+
+    /**
+     * The glasses occasionally answer IMU-ON with a stream of frozen
+     * frames (tick not advancing). If frames stall or the tick freezes,
+     * re-send the enable sequence.
+     */
+    private void watchdogLoop() {
+        int lastFrames = 0;
+        int lastTickSeen = 0;
+        while (running) {
+            try {
+                Thread.sleep(2000);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+            final int frames = framesReceived;
+            final int tick = lastTickValue;
+            if (frames > 10 && frames == lastFrames) {
+                System.err.println("head tracker: frame stream stalled, "
+                        + "re-enabling IMU");
+                device.startImu();
+            } else if (frames > 1000 && tick == lastTickSeen) {
+                System.err.println("head tracker: tick frozen (stale "
+                        + "sensor data), re-enabling IMU");
+                device.startImu();
+            }
+            lastFrames = frames;
+            lastTickSeen = tick;
+        }
+    }
+
+    /**
+     * One-line diagnostic snapshot: frame flow, tick, integration health.
+     */
+    public String getDebugString() {
+        return String.format(
+                "frames=%d tick=%d dt=%.2fms maxGyro=%.1fdps "
+                        + "calibrated=%b yaw=%.2fdeg pitch=%.2fdeg",
+                framesReceived, lastTickValue, lastDtMillis,
+                maxAbsGyroDps, calibrated, Math.toDegrees(getLookYaw()),
+                Math.toDegrees(getLookPitch()));
+    }
+
+    /**
+     * Makes the current head orientation the new "straight ahead".
+     */
+    public synchronized void recenter() {
+        centerYaw = fusedYaw;
+        centerPitch = fusedPitch;
+        smoothYaw = 0;
+        smoothPitch = 0;
+    }
+
+    /** Head yaw relative to the last recenter, radians. */
+    public synchronized double getLookYaw() {
+        return smoothYaw;
+    }
+
+    /** Head pitch relative to the last recenter, radians. */
+    public synchronized double getLookPitch() {
+        return smoothPitch;
+    }
+
+    /** False until the initial gyro bias calibration has finished. */
+    public boolean isCalibrated() {
+        return calibrated;
+    }
+
+    /** True while the reader thread is alive and frames may be flowing. */
+    public boolean isRunning() {
+        return running && readerThread.isAlive();
+    }
+
+    public void stop() {
+        running = false;
+        try {
+            readerThread.join(500);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        device.close();
+    }
+
+    private void readLoop() {
+        while (running) {
+            final int type = device.readFrame(frame);
+            if (type < 0) {
+                if (running)
+                    System.err.println("head tracker: device read failed "
+                            + "(unplugged?), stopping");
+                running = false;
+                return;
+            }
+            if (type == RayNeoHid.TYPE_IMU)
+                onSample();
+        }
+        running = false;
+    }
+
+    private void onSample() {
+        final float gx = frame.getFloat(16);
+        final float gy = frame.getFloat(20);
+        final float gz = frame.getFloat(24);
+        final float ax = frame.getFloat(4);
+        final float ay = frame.getFloat(8);
+        final float az = frame.getFloat(12);
+        final int tick = frame.getInt(40);
+
+        framesReceived++;
+        lastTickValue = tick;
+        final double absGyro = Math.sqrt(gx * gx + gy * gy + gz * gz);
+        if (absGyro > maxAbsGyroDps)
+            maxAbsGyroDps = absGyro;
+
+        if (!calibrated) {
+            calibrate(gx, gy, gz, ax, ay, az, tick, absGyro);
+            return;
+        }
+
+        final double dt;
+        if (hasTick) {
+            // The device tick is NOT microseconds (measured: ~50µs per
+            // unit) — useless for integration timing, kept only as a
+            // liveness signal for the watchdog. Wall clock at 500Hz is
+            // fine; jitter is smoothed by the output EMA.
+            dt = Math.min(Math.max((System.nanoTime() - lastSampleNanos)
+                    / 1_000_000_000.0, 0), 0.1);
+        } else {
+            dt = 0.002;
+        }
+        lastDtMillis = dt * 1000;
+        lastSampleNanos = System.nanoTime();
+        lastTick = tick;
+        hasTick = true;
+
+        if (absGyro < STATIONARY_THRESHOLD_DPS)
+            for (int i = 0; i < 3; i++) {
+                final double g = i == AXIS_PITCH ? gx : i == AXIS_YAW ? gy : gz;
+                gyroBias[i] += (g - gyroBias[i]) * BIAS_BLEND;
+            }
+
+        synchronized (this) {
+            fusedPitch += deadband(gx - gyroBias[AXIS_PITCH]) * DPS_TO_RAD * dt;
+            fusedYaw += deadband(gy - gyroBias[AXIS_YAW]) * DPS_TO_RAD * dt;
+            fusedRoll += deadband(gz - gyroBias[AXIS_ROLL]) * DPS_TO_RAD * dt;
+
+            // gravity reference: pitch rotates around sensor X (mixes the
+            // Y/Z gravity components), roll around sensor Z (mixes X/Y)
+            final double accelPitch = Math.atan2(az, ay);
+            final double accelRoll = Math.atan2(-ax, ay);
+            fusedPitch += (accelPitch - fusedPitch) * ACCEL_BLEND;
+            fusedRoll += (accelRoll - fusedRoll) * ACCEL_BLEND;
+
+            final double yaw = fusedYaw - centerYaw;
+            final double pitch = fusedPitch - centerPitch;
+            smoothYaw += (yaw - smoothYaw) * OUTPUT_SMOOTHING;
+            smoothPitch += (pitch - smoothPitch) * OUTPUT_SMOOTHING;
+        }
+    }
+
+    /**
+     * Gyro bias calibration: only stationary samples contribute — if
+     * the user is already turning, those samples would poison the bias
+     * with several dps of phantom drift. Give up after ~10s and accept
+     * whatever we have.
+     */
+    private void calibrate(final float gx, final float gy, final float gz,
+                           final float ax, final float ay, final float az,
+                           final int tick, final double absGyro) {
+        calibrationAttempts++;
+        if (absGyro < STATIONARY_THRESHOLD_DPS
+                || calibrationAttempts > CALIBRATION_TIMEOUT_SAMPLES) {
+            gyroBias[AXIS_PITCH] += gx;
+            gyroBias[AXIS_YAW] += gy;
+            gyroBias[AXIS_ROLL] += gz;
+            calibrationSamples++;
+        }
+        if (calibrationSamples >= CALIBRATION_SAMPLES) {
+            for (int i = 0; i < 3; i++)
+                gyroBias[i] /= calibrationSamples;
+            // jump-start the tilt estimate from gravity; otherwise the
+            // filter would take a second to converge and the boot
+            // recenter would capture the unconverged zero
+            fusedPitch = Math.atan2(az, ay);
+            fusedRoll = Math.atan2(-ax, ay);
+            calibrated = true;
+            lastTick = tick;
+            lastSampleNanos = System.nanoTime();
+            hasTick = true;
+        }
+    }
+
+    private static double deadband(final double rateDps) {
+        return Math.abs(rateDps) < DEADBAND_DPS ? 0 : rateDps;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/HeadTrackingManager.java
new file mode 100644 (file)
index 0000000..bda7ff8
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.headtrack;
+
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+
+/**
+ * Hot-plug manager for XR glasses head tracking. Polls for RayNeo
+ * glasses every two seconds:
+ *
+ * <ul>
+ *   <li>glasses plugged in → open the HID pipe, start a
+ *       {@link HeadTracker}, attach a {@link HeadLookController} to the
+ *       view's frame listeners</li>
+ *   <li>glasses unplugged (tracker read fails) → stop the tracker and
+ *       detach the controller, so the camera is no longer driven by a
+ *       dead device</li>
+ *   <li>plugged back in → fresh tracker, fresh boot recenter</li>
+ * </ul>
+ *
+ * <p>Permission failures are reported once, then retried silently —
+ * the user may install the udev rule while the application is running.</p>
+ */
+public final class HeadTrackingManager {
+
+    private static final long POLL_INTERVAL_MS = 2000;
+
+    private final ViewPanel viewPanel;
+    private final Thread thread;
+
+    private volatile boolean running = true;
+    private HeadTracker tracker;
+    private HeadLookController controller;
+    private boolean failureReported;
+
+    public HeadTrackingManager(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+        thread = new Thread(this::pollLoop, "head-tracking-hotplug");
+        thread.setDaemon(true);
+    }
+
+    public void start() {
+        thread.start();
+    }
+
+    /** Active tracker, or null when no glasses are connected. */
+    public HeadTracker getTracker() {
+        return tracker;
+    }
+
+    public void stop() {
+        running = false;
+        try {
+            thread.join(1000);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        disconnect();
+    }
+
+    private void pollLoop() {
+        while (running) {
+            poll();
+            try {
+                Thread.sleep(POLL_INTERVAL_MS);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+    }
+
+    private void poll() {
+        if (tracker != null && !tracker.isRunning()) {
+            System.out.println("head tracking: glasses disconnected");
+            disconnect();
+        }
+        if (tracker != null)
+            return;
+
+        try {
+            final RayNeoHid glasses = RayNeoHid.open();
+            if (glasses == null)
+                return;
+            tracker = new HeadTracker(glasses);
+            tracker.start();
+            controller = new HeadLookController(tracker, viewPanel);
+            viewPanel.addFrameListener(controller);
+            failureReported = false;
+            System.out.println("head tracking: RayNeo glasses detected, "
+                    + "calibrating (hold still for a second)");
+        } catch (final Exception e) {
+            tracker = null;
+            if (!failureReported) {
+                failureReported = true;
+                System.err.println("head tracking unavailable: "
+                        + e.getMessage());
+            }
+        }
+    }
+
+    private void disconnect() {
+        if (controller != null) {
+            viewPanel.removeFrameListener(controller);
+            controller = null;
+        }
+        if (tracker != null) {
+            tracker.stop();
+            tracker = null;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/headtrack/RayNeoHid.java
new file mode 100644 (file)
index 0000000..c7a47a8
--- /dev/null
@@ -0,0 +1,149 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.headtrack;
+
+import com.sun.jna.Library;
+import com.sun.jna.Memory;
+import com.sun.jna.Native;
+
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Raw HID transport for RayNeo AR glasses (T&amp;A Mobile Phones,
+ * VID 1BBB, PID AF50) via Linux hidraw.
+ *
+ * <p>IMU sample layout inside a 0x65 frame (all little-endian):</p>
+ * <ul>
+ *   <li>float acc[3]  at offset 4  (m/s²)</li>
+ *   <li>float gyro[3] at offset 16 (degrees/second)</li>
+ *   <li>float temperature at 28, float magnet[0..1] at 32</li>
+ *   <li>uint32 tick at 40 (NOT microseconds — ~50µs per unit; use only
+ *       as a liveness signal, never for integration timing)</li>
+ *   <li>float psensor at 44, float lsensor at 48, float magnet[2] at 52</li>
+ * </ul>
+ *
+ * <p>Startup sequence that yields live data: psensor enable (0x38) THEN
+ * IMU on (0x01). Without the psensor enable the glasses may stream
+ * frozen sensor values.</p>
+ *
+ * <p>Permissions: /dev/hidraw* is root-only by default. Install a udev
+ * rule (e.g. {@code 99-rayneo-glasses.rules} with MODE="0666" for
+ * VID 1BBB PID AF50) to allow user access.</p>
+ */
+public final class RayNeoHid implements AutoCloseable {
+
+    public static final int FRAME_SIZE = 64;
+
+    private static final int O_RDWR = 0x02;
+    private static final byte MAGIC_OUT = 0x66;
+    private static final byte MAGIC_IN = (byte) 0x99;
+
+    /** Frame type: IMU sample. */
+    public static final int TYPE_IMU = 0x65;
+
+    private static final int CMD_DEVICE_INFO = 0x00;
+    private static final int CMD_IMU_ON = 0x01;
+    private static final int CMD_IMU_OFF = 0x02;
+    private static final int CMD_PSENSOR_ENABLE = 0x38;
+
+    private interface CLib extends Library {
+        CLib INSTANCE = Native.load("c", CLib.class);
+
+        int open(String path, int flags);
+
+        int close(int fd);
+
+        int read(int fd, Memory buffer, int count);
+
+        int write(int fd, Memory buffer, int count);
+    }
+
+    private final int fd;
+    private final Memory frameBuffer = new Memory(FRAME_SIZE);
+
+    private RayNeoHid(final int fd) {
+        this.fd = fd;
+    }
+
+    /**
+     * Finds the hidraw node of the glasses by scanning sysfs uevent data,
+     * then opens it. Returns null when the glasses are not connected.
+     */
+    public static RayNeoHid open() throws IOException {
+        final Path hidrawDir = Path.of("/sys/class/hidraw");
+        if (!Files.isDirectory(hidrawDir))
+            return null;
+
+        try (DirectoryStream<Path> nodes = Files.newDirectoryStream(hidrawDir)) {
+            for (final Path node : nodes) {
+                final Path uevent = node.resolve("device/uevent");
+                if (!Files.isRegularFile(uevent))
+                    continue;
+                final String content = Files.readString(uevent);
+                if (content.contains("00001BBB") && content.contains("0000AF50")) {
+                    final Path dev = Path.of("/dev", node.getFileName().toString());
+                    final int fd = CLib.INSTANCE.open(dev.toString(), O_RDWR);
+                    if (fd < 0)
+                        throw new IOException("cannot open " + dev
+                                + " (permissions? install udev rule "
+                                + "99-rayneo-glasses.rules)");
+                    return new RayNeoHid(fd);
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Enables the proximity sensor pipeline and starts IMU streaming.
+     * Both commands are required; psensor-first ordering matters.
+     */
+    public void startImu() {
+        sendCommand(CMD_PSENSOR_ENABLE, 0);
+        sendCommand(CMD_IMU_ON, 0);
+    }
+
+    /**
+     * Stops IMU streaming.
+     */
+    public void stopImu() {
+        sendCommand(CMD_IMU_OFF, 0);
+    }
+
+    private void sendCommand(final int command, final int value) {
+        synchronized (frameBuffer) {
+            frameBuffer.clear();
+            frameBuffer.setByte(0, MAGIC_OUT);
+            frameBuffer.setByte(1, (byte) command);
+            frameBuffer.setByte(2, (byte) value);
+            CLib.INSTANCE.write(fd, frameBuffer, FRAME_SIZE);
+        }
+    }
+
+    /**
+     * Blocking read of one 64-byte frame. Returns the frame type byte
+     * (e.g. {@link #TYPE_IMU}) or -1 on error/closed device. The frame
+     * bytes are left in the given buffer for the caller to parse.
+     */
+    public int readFrame(final Memory out) {
+        final int n = CLib.INSTANCE.read(fd, out, FRAME_SIZE);
+        if (n != FRAME_SIZE || out.getByte(0) != MAGIC_IN)
+            return -1;
+        return out.getByte(1) & 0xFF;
+    }
+
+    @Override
+    public void close() {
+        try {
+            stopImu();
+        } catch (final Exception ignored) {
+            // device may already be unplugged
+        }
+        CLib.INSTANCE.close(fd);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/Connexion3D.java
new file mode 100644 (file)
index 0000000..bcd13c0
--- /dev/null
@@ -0,0 +1,48 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
+
+/**
+ * I have Space Mouse Compact 3D Connexion mouse: https://3dconnexion.com/us/product/spacemouse-compact/
+ *
+ * I discovered that it is possible to read raw data from it by reading /dev/hidraw4 file.
+ *
+ * TODO: reverse engineer the data format and implement a driver for it.
+ */
+
+public class Connexion3D {
+
+    /**
+     * Creates a new Connexion3D instance.
+     */
+    public Connexion3D() {
+    }
+
+    /**
+     * Reads raw data from the 3Dconnexion device for testing purposes.
+     *
+     * @param args command line arguments (ignored)
+     * @throws IOException if the device cannot be read
+     */
+    public static void main(final String[] args) throws IOException {
+
+        final BufferedReader in = new BufferedReader(new FileReader(
+                "/dev/hidraw4"));
+
+
+        // for testing purposes
+        while (true) {
+            System.out.print(in.read() + " ");
+            System.out.println("\n");
+        }
+
+        // in.close();
+
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/InputManager.java
new file mode 100644 (file)
index 0000000..cd7db6c
--- /dev/null
@@ -0,0 +1,378 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.gui.FrameListener;
+import eu.svjatoslav.aukio.e3d.gui.ViewFrame;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+
+import java.awt.*;
+import java.awt.event.*;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Manages mouse and keyboard input for the 3D view.
+ *
+ * <p>Handles mouse/keyboard events, tracks pressed keys and mouse state,
+ * and forwards events to the appropriate handlers. Also provides default camera
+ * control via mouse dragging (look around) and mouse wheel (vertical movement).</p>
+ *
+ * @see ViewPanel#getInputManager()
+ */
+public class InputManager implements
+        MouseMotionListener, KeyListener, MouseListener, MouseWheelListener, FrameListener {
+
+    private final Map<Integer, Long> pressedKeysToPressedTimeMap = new HashMap<>();
+    private final List<MouseEvent> detectedMouseEvents = new ArrayList<>();
+    private final List<KeyEvent> detectedKeyEvents = new ArrayList<>();
+    private final Point2D mouseDelta = new Point2D();
+    private final MouseEvent reusableHoverEvent = new MouseEvent(new Point2D(), 0);
+    private final Point2D reusableMouseLocation = new Point2D();
+    private final ViewPanel viewPanel;
+    private int wheelVerticalUnits = 0;
+    private int wheelHorizontalUnits = 0;
+    private Point2D oldMouseCoordinatesWhenDragging;
+    private Point2D currentMouseLocation;
+    private boolean mouseMoved;
+    private boolean mouseWithinWindow = false;
+    private double cameraYaw = 0;
+    private double cameraPitch = 0;
+    private double cameraRoll = 0;
+
+    /**
+     * Creates an input manager attached to the given view panel.
+     *
+     * @param viewPanel the view panel to receive input from
+     */
+    public InputManager(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+        bind(viewPanel);
+    }
+
+    /**
+     * Processes accumulated input events and updates camera based on mouse drag/wheel.
+     *
+     * @param viewPanel                  the view panel
+     * @param millisecondsSinceLastFrame time since last frame (unused)
+     * @return {@code true} if a view repaint is needed
+     */
+    @Override
+    public boolean onFrame(final ViewPanel viewPanel, final int millisecondsSinceLastFrame) {
+        boolean viewUpdateNeeded = handleKeyboardEvents();
+        viewUpdateNeeded |= handleMouseClicksAndHover(viewPanel);
+        viewUpdateNeeded |= handleMouseDragging();
+        viewUpdateNeeded |= handleMouseScrolling();
+        return viewUpdateNeeded;
+    }
+
+    /**
+     * Binds this input manager to listen for events on the given component.
+     * Also registers a global {@link KeyEventDispatcher} so keyboard shortcuts
+     * (F11, F12, SHIFT+F11) work even when the ViewPanel does not have focus.
+     *
+     * @param component the component to attach listeners to
+     */
+    private void bind(final Component component) {
+        component.addMouseMotionListener(this);
+        component.addKeyListener(this);
+        component.addMouseListener(this);
+        component.addMouseWheelListener(this);
+    }
+
+    /**
+     * Processes all accumulated keyboard events and forwards them to the current focus owner.
+     *
+     * @return {@code true} if any event handler requested a repaint
+     */
+    private boolean handleKeyboardEvents() {
+        final KeyboardInputHandler currentFocusOwner = viewPanel.getKeyboardFocusStack().getCurrentFocusOwner();
+
+        if (currentFocusOwner == null)
+            return false;
+
+        boolean viewUpdateNeeded = false;
+        synchronized (detectedKeyEvents) {
+            for (int i = 0; i < detectedKeyEvents.size(); i++)
+                viewUpdateNeeded |= processKeyEvent(currentFocusOwner, detectedKeyEvents.get(i));
+            detectedKeyEvents.clear();
+        }
+        return viewUpdateNeeded;
+    }
+
+    /**
+     * Processes a single keyboard event by dispatching to the focus owner.
+     *
+     * @param currentFocusOwner the component that currently has keyboard focus
+     * @param keyEvent          the keyboard event to process
+     * @return {@code true} if the handler requested a repaint
+     */
+    private boolean processKeyEvent(KeyboardInputHandler currentFocusOwner, KeyEvent keyEvent) {
+        switch (keyEvent.getID()) {
+            case KeyEvent.KEY_PRESSED:
+                return currentFocusOwner.keyPressed(keyEvent, viewPanel);
+
+            case KeyEvent.KEY_RELEASED:
+                return currentFocusOwner.keyReleased(keyEvent, viewPanel);
+        }
+        return false;
+    }
+
+    /**
+     * Handles mouse clicks and hover detection.
+     * Sets up the mouse event in the rendering context for shape hit testing.
+     *
+     * @param viewPanel the view panel
+     * @return {@code true} if a repaint is needed
+     */
+    private synchronized boolean handleMouseClicksAndHover(final ViewPanel viewPanel) {
+        boolean rerenderNeeded = false;
+        MouseEvent event = findClickLocationToTrace();
+        if (event != null) {
+            rerenderNeeded = true;
+        } else {
+            if (mouseMoved) {
+                mouseMoved = false;
+                rerenderNeeded = true;
+            }
+
+            if (currentMouseLocation != null) {
+                reusableHoverEvent.coordinate.x = currentMouseLocation.x;
+                reusableHoverEvent.coordinate.y = currentMouseLocation.y;
+                event = reusableHoverEvent;
+            }
+        }
+
+        if (viewPanel.getRenderingContext() != null)
+            viewPanel.getRenderingContext().setMouseEvent(event);
+
+        return rerenderNeeded;
+    }
+
+    private MouseEvent findClickLocationToTrace() {
+        synchronized (detectedMouseEvents) {
+            if (detectedMouseEvents.isEmpty())
+                return null;
+
+            return detectedMouseEvents.remove(0);
+        }
+    }
+
+    /**
+     * Returns whether the specified key is currently pressed.
+     *
+     * @param keyCode the key code (from {@link java.awt.event.KeyEvent})
+     * @return {@code true} if the key is currently pressed
+     */
+    public boolean isKeyPressed(final int keyCode) {
+        return pressedKeysToPressedTimeMap.containsKey(keyCode);
+    }
+
+    @Override
+    public void keyPressed(final KeyEvent evt) {
+        if (evt.getKeyCode() == java.awt.event.KeyEvent.VK_F12) {
+            viewPanel.showDeveloperToolsPanel();
+            return;
+        }
+        if (evt.getKeyCode() == java.awt.event.KeyEvent.VK_F11) {
+            final ViewFrame frame = findParentViewFrame();
+            if (frame != null) {
+                if (evt.isShiftDown()) {
+                    // SHIFT+F11: toggle stereo + fullscreen together
+                    final boolean stereoOn = !viewPanel.isStereoModeEnabled();
+                    viewPanel.setStereoModeEnabled(stereoOn);
+                    frame.setFullscreen(stereoOn);
+                } else {
+                    // F11: toggle fullscreen only
+                    frame.toggleFullscreen();
+                }
+            }
+            return;
+        }
+        // +/- adjusts stereo IPD (inter-pupillary distance) when stereo is active
+        if (viewPanel.isStereoModeEnabled()) {
+            final int code = evt.getKeyCode();
+            if (code == java.awt.event.KeyEvent.VK_PLUS
+                    || code == java.awt.event.KeyEvent.VK_EQUALS
+                    || code == java.awt.event.KeyEvent.VK_ADD) {
+                viewPanel.setStereoIPD(viewPanel.getStereoIPD() + 0.5);
+                return;
+            }
+            if (code == java.awt.event.KeyEvent.VK_MINUS
+                    || code == java.awt.event.KeyEvent.VK_SUBTRACT) {
+                viewPanel.setStereoIPD(Math.max(0.5, viewPanel.getStereoIPD() - 0.5));
+                return;
+            }
+        }
+        synchronized (detectedKeyEvents) {
+            pressedKeysToPressedTimeMap.put(evt.getKeyCode(), System.currentTimeMillis());
+            detectedKeyEvents.add(evt);
+        }
+    }
+
+    @Override
+    public void keyReleased(final KeyEvent evt) {
+        synchronized (detectedKeyEvents) {
+            pressedKeysToPressedTimeMap.remove(evt.getKeyCode());
+            detectedKeyEvents.add(evt);
+        }
+    }
+
+    @Override
+    public void keyTyped(final KeyEvent e) {
+    }
+
+    @Override
+    public void mouseClicked(final java.awt.event.MouseEvent e) {
+        synchronized (detectedMouseEvents) {
+            detectedMouseEvents.add(new MouseEvent(e.getX(), e.getY(), e.getButton()));
+        }
+    }
+
+    @Override
+    public void mouseDragged(final java.awt.event.MouseEvent evt) {
+        reusableMouseLocation.x = evt.getX();
+        reusableMouseLocation.y = evt.getY();
+
+        if (oldMouseCoordinatesWhenDragging == null) {
+            oldMouseCoordinatesWhenDragging = new Point2D(reusableMouseLocation.x, reusableMouseLocation.y);
+            return;
+        }
+
+        mouseDelta.x += reusableMouseLocation.x - oldMouseCoordinatesWhenDragging.x;
+        mouseDelta.y += reusableMouseLocation.y - oldMouseCoordinatesWhenDragging.y;
+
+        oldMouseCoordinatesWhenDragging.x = reusableMouseLocation.x;
+        oldMouseCoordinatesWhenDragging.y = reusableMouseLocation.y;
+    }
+
+    @Override
+    public void mouseEntered(final java.awt.event.MouseEvent e) {
+        mouseWithinWindow = true;
+    }
+
+    @Override
+    public synchronized void mouseExited(final java.awt.event.MouseEvent e) {
+        mouseWithinWindow = false;
+        currentMouseLocation = null;
+    }
+
+    @Override
+    public synchronized void mouseMoved(final java.awt.event.MouseEvent e) {
+        if (currentMouseLocation == null)
+            currentMouseLocation = new Point2D(e.getX(), e.getY());
+        else {
+            currentMouseLocation.x = e.getX();
+            currentMouseLocation.y = e.getY();
+        }
+        mouseMoved = true;
+    }
+
+    @Override
+    public void mousePressed(final java.awt.event.MouseEvent e) {
+        // Extra buttons (mouse back/forward) never produce an AWT CLICKED
+        // event on Linux (only PRESSED/RELEASED), so they are delivered
+        // to the component already on press.
+        if (e.getButton() > 3) {
+            synchronized (detectedMouseEvents) {
+                detectedMouseEvents.add(
+                        new MouseEvent(e.getX(), e.getY(), e.getButton()));
+            }
+        }
+        // Initialize camera rotation state from current camera orientation.
+        // This prevents a jump when the camera was programmatically positioned
+        // with a non-default rotation before the user started dragging.
+        final Camera camera = viewPanel.getCamera();
+        final double[] angles = camera.getTransform().getRotation().toAngles();
+        cameraYaw = angles[0];
+        cameraPitch = angles[1];
+        cameraRoll = angles[2];
+    }
+
+    @Override
+    public void mouseReleased(final java.awt.event.MouseEvent evt) {
+        oldMouseCoordinatesWhenDragging = null;
+    }
+
+    @Override
+    public void mouseWheelMoved(final java.awt.event.MouseWheelEvent evt) {
+        // horizontal wheel input arrives as shift-modified vertical
+        // rotation (AWT convention on all platforms)
+        if (evt.isShiftDown())
+            wheelHorizontalUnits += evt.getWheelRotation();
+        else
+            wheelVerticalUnits += evt.getWheelRotation();
+    }
+
+    /**
+     * Routes scroll wheel input: to the focused GUI component when it
+     * consumes it, otherwise to the camera (vertical = up/down,
+     * horizontal = strafe left/right).
+     */
+    private boolean handleMouseScrolling() {
+        final int vertical = wheelVerticalUnits;
+        final int horizontal = wheelHorizontalUnits;
+        wheelVerticalUnits = 0;
+        wheelHorizontalUnits = 0;
+        if (vertical == 0 && horizontal == 0)
+            return false;
+
+        final KeyboardInputHandler focusOwner = viewPanel
+                .getKeyboardFocusStack().getCurrentFocusOwner();
+        if (focusOwner instanceof MouseInteractionController component
+                && component.mouseWheelMoved(vertical, horizontal))
+            // consumed by the focused component
+            return true;
+
+        final Camera camera = viewPanel.getCamera();
+        final double actualAcceleration = 50 * camera.cameraAcceleration * (1 + (camera.getMovementSpeed() / 10));
+        camera.getMovementVector().y += (vertical * actualAcceleration);
+        camera.getMovementVector().x += (horizontal * actualAcceleration);
+        camera.enforceSpeedLimit();
+        return true;
+    }
+
+    private boolean handleMouseDragging() {
+        if (mouseDelta.isZero()) {
+            return false;
+        }
+
+        cameraYaw -= mouseDelta.x / 50.0;
+        cameraPitch -= mouseDelta.y / 50.0;
+
+        cameraPitch = Math.max(-Math.PI / 2 + 0.001,
+                      Math.min( Math.PI / 2 - 0.001, cameraPitch));
+
+        final Camera camera = viewPanel.getCamera();
+        camera.getTransform().getRotation().set(
+                Quaternion.fromAngles(cameraYaw, cameraPitch, cameraRoll));
+
+        mouseDelta.zero();
+        return true;
+    }
+
+    /**
+     * Walks up the component hierarchy from the view panel to find the
+     * parent {@link ViewFrame}, if any.
+     *
+     * @return the parent ViewFrame, or {@code null} if the ViewPanel is
+     *         not embedded in a ViewFrame
+     */
+    private ViewFrame findParentViewFrame() {
+        java.awt.Container parent = viewPanel.getParent();
+        while (parent != null) {
+            if (parent instanceof ViewFrame)
+                return (ViewFrame) parent;
+            parent = parent.getParent();
+        }
+        return null;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardFocusStack.java
new file mode 100644 (file)
index 0000000..dc9360f
--- /dev/null
@@ -0,0 +1,102 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+
+/**
+ * Manages keyboard focus for interactive 3D components.
+ *
+ * <p>Exactly one {@link KeyboardInputHandler} has keyboard focus at a time.
+ * When a component gains focus (e.g., by being clicked), the previous focus
+ * owner is notified and forgotten. When the component releases focus (ESC or
+ * middle mouse button), focus falls back to the default handler — never to a
+ * previously focused component (there is no focus stack).</p>
+ *
+ * <p>The default handler is a {@link WorldNavigationUserInputTracker}, which
+ * handles WASD/arrow-key camera movement while no component has focus.</p>
+ *
+ * <p><b>Focus flow example:</b></p>
+ * <pre>{@code
+ * // Initial state: WorldNavigationUserInputTracker has focus (camera movement)
+ * // User clicks on a text editor:
+ * focus.pushFocusOwner(textEditor);
+ * // Now textEditor receives keyboard events
+ *
+ * // User presses ESC or middle-clicks the editor:
+ * focus.popFocusOwner();
+ * // Camera movement is active again
+ * }</pre>
+ *
+ * @see KeyboardInputHandler the interface that focus owners must implement
+ * @see WorldNavigationUserInputTracker default handler for camera navigation
+ */
+public class KeyboardFocusStack {
+
+    private final ViewPanel viewPanel;
+    private final WorldNavigationUserInputTracker defaultInputHandler = new WorldNavigationUserInputTracker();
+    private KeyboardInputHandler currentUserInputHandler;
+
+    /**
+     * Creates a new focus manager for the given view panel, with
+     * {@link WorldNavigationUserInputTracker} as the default focus owner.
+     *
+     * @param viewPanel the view panel this focus manager belongs to
+     */
+    public KeyboardFocusStack(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+        currentUserInputHandler = defaultInputHandler;
+        currentUserInputHandler.focusReceived(viewPanel);
+    }
+
+    /**
+     * Returns the handler that currently has keyboard focus. When no
+     * component is focused, this is the default camera navigation handler.
+     *
+     * @return the current focus owner
+     */
+    public KeyboardInputHandler getCurrentFocusOwner() {
+        return currentUserInputHandler;
+    }
+
+    /**
+     * Releases focus from the current focus owner; focus falls back to the
+     * default camera navigation handler. No previously focused component is
+     * restored — focus is single-level, not a stack.
+     */
+    public void popFocusOwner() {
+        if (currentUserInputHandler == defaultInputHandler)
+            return;
+
+        currentUserInputHandler.focusLost(viewPanel);
+        currentUserInputHandler = defaultInputHandler;
+        currentUserInputHandler.focusReceived(viewPanel);
+    }
+
+    /**
+     * Gives keyboard focus to the given handler. The previous focus owner is
+     * notified via {@link KeyboardInputHandler#focusLost} and forgotten.
+     *
+     * <p>If the given handler is already the current focus owner, this method
+     * does nothing and returns {@code false}.</p>
+     *
+     * @param newInputHandler the handler to receive keyboard focus
+     * @return {@code true} if the view needs to be repainted as a result
+     */
+    public boolean pushFocusOwner(final KeyboardInputHandler newInputHandler) {
+        boolean updateNeeded = false;
+
+        if (currentUserInputHandler == newInputHandler)
+            return false;
+
+        if (currentUserInputHandler != null)
+            updateNeeded = currentUserInputHandler.focusLost(viewPanel);
+
+        currentUserInputHandler = newInputHandler;
+        updateNeeded |= currentUserInputHandler.focusReceived(viewPanel);
+
+        return updateNeeded;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardHelper.java
new file mode 100644 (file)
index 0000000..a2d32a2
--- /dev/null
@@ -0,0 +1,124 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import java.awt.event.InputEvent;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Utility class providing keyboard key code constants and modifier detection methods.
+ *
+ * <p>Provides named constants for common key codes and static helper methods
+ * to check whether modifier keys (Ctrl, Alt, Shift) are pressed in a given
+ * event modifier mask.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * public boolean keyPressed(KeyEvent event, ViewPanel viewPanel) {
+ *     if (event.getKeyCode() == KeyboardHelper.ENTER) {
+ *         // Handle Enter key
+ *     }
+ *     if (KeyboardHelper.isCtrlPressed(event.getModifiersEx())) {
+ *         // Handle Ctrl+key combination
+ *     }
+ *     return true;
+ * }
+ * }</pre>
+ *
+ * @see KeyboardInputHandler the interface for receiving keyboard events
+ */
+public class KeyboardHelper {
+
+    /**
+     * Private constructor to prevent instantiation of this utility class.
+     */
+    private KeyboardHelper() {
+    }
+
+    /** Key code for the Tab key. */
+    public static final int TAB = 9;
+    /** Key code for the Down arrow key. */
+    public static final int DOWN = 40;
+    /** Key code for the Up arrow key. */
+    public static final int UP = 38;
+    /** Key code for the Right arrow key. */
+    public static final int RIGHT = 39;
+    /** Key code for the Left arrow key. */
+    public static final int LEFT = 37;
+    /** Key code for the Page Down key. */
+    public static final int PGDOWN = 34;
+    /** Key code for the Page Up key. */
+    public static final int PGUP = 33;
+    /** Key code for the Home key. */
+    public static final int HOME = 36;
+    /** Key code for the End key. */
+    public static final int END = 35;
+    /** Key code for the Delete key. */
+    public static final int DEL = 127;
+    /** Key code for the Enter/Return key. */
+    public static final int ENTER = 10;
+    /** Key code for the Backspace key. */
+    public static final int BACKSPACE = 8;
+    /** Key code for the Escape key. */
+    public static final int ESC = 27;
+    /** Key code for the Shift key. */
+    public static final int SHIFT = 16;
+
+    private static final Set<Integer> nonText;
+
+    static {
+        nonText = new HashSet<>();
+        nonText.add(DOWN);
+        nonText.add(UP);
+        nonText.add(LEFT);
+        nonText.add(RIGHT);
+
+        nonText.add(SHIFT);
+        nonText.add(ESC);
+    }
+
+    /**
+     * Checks if the Alt key is pressed in the given modifier mask.
+     *
+     * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()}
+     * @return {@code true} if Alt is pressed
+     */
+    public static boolean isAltPressed(final int modifiersEx) {
+        return (modifiersEx | InputEvent.ALT_DOWN_MASK) == modifiersEx;
+    }
+
+    /**
+     * Checks if the Ctrl key is pressed in the given modifier mask.
+     *
+     * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()}
+     * @return {@code true} if Ctrl is pressed
+     */
+    public static boolean isCtrlPressed(final int modifiersEx) {
+        return (modifiersEx | InputEvent.CTRL_DOWN_MASK) == modifiersEx;
+    }
+
+    /**
+     * Checks if the Shift key is pressed in the given modifier mask.
+     *
+     * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()}
+     * @return {@code true} if Shift is pressed
+     */
+    public static boolean isShiftPressed(final int modifiersEx) {
+        return (modifiersEx | InputEvent.SHIFT_DOWN_MASK) == modifiersEx;
+    }
+
+    /**
+     * Determines whether the given key code represents a text-producing key
+     * (as opposed to navigation or modifier keys like arrows, Shift, Escape).
+     *
+     * @param keyCode the key code to check
+     * @return {@code true} if the key produces text input
+     */
+    public static boolean isText(final int keyCode) {
+        return !nonText.contains(keyCode);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/KeyboardInputHandler.java
new file mode 100644 (file)
index 0000000..e6ed27b
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * This is the process:
+ * <p>
+ * 1. Component receives focus, perhaps because user clicked on it with the mouse.
+ * 2. Now component will receive user key press and release events from the keyboard.
+ * 3. Component loses focus. Perhaps user chose another component to interact with.
+ */
+public interface KeyboardInputHandler {
+
+    /**
+     * Called when the component loses keyboard focus.
+     *
+     * @param viewPanel the view panel that owns this handler
+     * @return {@code true} if view needs to be re-rendered
+     */
+    boolean focusLost(ViewPanel viewPanel);
+
+    /**
+     * Called when the component receives keyboard focus.
+     *
+     * @param viewPanel the view panel that owns this handler
+     * @return {@code true} if view needs to be re-rendered
+     */
+    boolean focusReceived(ViewPanel viewPanel);
+
+    /**
+     * Called when a key is pressed while the component has focus.
+     *
+     * @param event     the key event
+     * @param viewPanel the view panel that owns this handler
+     * @return {@code true} if view needs to be re-rendered
+     */
+    boolean keyPressed(KeyEvent event, ViewPanel viewPanel);
+
+    /**
+     * Called when a key is released while the component has focus.
+     *
+     * @param event     the key event
+     * @param viewPanel the view panel that owns this handler
+     * @return {@code true} if view needs to be re-rendered
+     */
+    boolean keyReleased(KeyEvent event, ViewPanel viewPanel);
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseEvent.java
new file mode 100644 (file)
index 0000000..819d9b0
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+
+/**
+ * Represents mouse event.
+ */
+public class MouseEvent {
+
+    /** Mouse over (no button pressed). */
+    public static final int BUTTON_HOVER = 0;
+    /** Left mouse button. */
+    public static final int BUTTON_LEFT = 1;
+    /** Middle mouse button. */
+    public static final int BUTTON_MIDDLE = 2;
+    /** Right mouse button. */
+    public static final int BUTTON_RIGHT = 3;
+    /**
+     * Mouse back button. AWT on Linux reports X buttons 8/9 as 6/7 and
+     * delivers them only as PRESSED/RELEASED, never CLICKED; other
+     * platforms may use 4/5 — handle both.
+     */
+    public static final int BUTTON_BACK = 6;
+    /** Mouse forward button (AWT 7 on Linux = X button 9). */
+    public static final int BUTTON_FORWARD = 7;
+
+    /**
+     * Mouse coordinate in screen space (pixels) relative to top left corner of the screen
+     * when mouse button was clicked.
+     */
+    public Point2D coordinate;
+
+    /**
+     * One of {@link #BUTTON_HOVER}, {@link #BUTTON_LEFT},
+     * {@link #BUTTON_MIDDLE}, {@link #BUTTON_RIGHT}.
+     */
+    public int button;
+
+    MouseEvent(final int x, final int y, final int button) {
+        this(new Point2D(x, y), button);
+    }
+
+    MouseEvent(final Point2D coordinate, final int button) {
+        this.coordinate = coordinate;
+        this.button = button;
+    }
+
+    @Override
+    public String toString() {
+        return "MouseEvent{" +
+                "coordinate=" + coordinate +
+                ", button=" + button +
+                '}';
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/MouseInteractionController.java
new file mode 100644 (file)
index 0000000..d0b8800
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+/**
+ * Interface that allows to handle mouse events.
+ */
+public interface MouseInteractionController {
+
+    /**
+     * Called when mouse is clicked on component.
+     *
+     * @param button the mouse button that was clicked (1 = left, 2 = middle, 3 = right)
+     * @return {@code true} if view update is needed as a consequence of this mouse click
+     */
+    boolean mouseClicked(int button);
+
+    /**
+     * Called when mouse is clicked on component, with the exact texture
+     * coordinates of the clicked point when the hit shape is textured.
+     *
+     * <p>The default implementation ignores the texture coordinates and
+     * delegates to {@link #mouseClicked(int)}. Components that need to know
+     * WHERE on their surface the click landed (e.g. to forward the click
+     * into a captured application window) override this method.</p>
+     *
+     * @param button    the mouse button that was clicked (1 = left, 2 = middle, 3 = right)
+     * @param textureU  texture-space X of the clicked point in primary-texture
+     *                  pixels, or {@link Double#NaN} when the hit shape has no texture
+     * @param textureV  texture-space Y of the clicked point in primary-texture
+     *                  pixels, or {@link Double#NaN} when the hit shape has no texture
+     * @return {@code true} if view update is needed as a consequence of this mouse click
+     */
+    default boolean mouseClicked(final int button, final double textureU,
+                                 final double textureV) {
+        return mouseClicked(button);
+    }
+
+    /**
+     * Called when the mouse wheel is turned while this component has
+     * keyboard focus. Both axes are reported: horizontal wheel input
+     * arrives from AWT as shift-modified vertical rotation and is
+     * separated by the input manager.
+     *
+     * <p>Returning {@code false} lets the wheel fall through to the
+     * default camera movement.</p>
+     *
+     * @param verticalUnits   wheel notches; positive = wheel away from
+     *                        the user (content scrolls down)
+     * @param horizontalUnits wheel notches; positive = scroll right
+     * @return {@code true} if the component consumed the scroll
+     */
+    default boolean mouseWheelMoved(final int verticalUnits,
+                                    final int horizontalUnits) {
+        return false;
+    }
+
+    /**
+     * Called when the mouse hovers over this component, with the exact
+     * texture coordinates of the hovered point when the hit shape is
+     * textured (NaN otherwise). Allows components that forward input into
+     * a captured application window to track the pointer position.
+     *
+     * @return {@code true} if view update is needed
+     */
+    default boolean mouseHover(final double textureU, final double textureV) {
+        return false;
+    }
+
+    /**
+     * Called when mouse gets over given component.
+     *
+     * @return <code>true</code> if view update is needed as a consequence of this mouse enter.
+     */
+    boolean mouseEntered();
+
+    /**
+     * Called when mouse leaves screen area occupied by component.
+     *
+     * @return <code>true</code> if view update is needed as a consequence of this mouse exit.
+     */
+    boolean mouseExited();
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/WorldNavigationUserInputTracker.java
new file mode 100644 (file)
index 0000000..dcdc78b
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
+
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.gui.FrameListener;
+
+import java.awt.event.KeyEvent;
+
+/**
+ * Default keyboard input handler that translates arrow key presses into camera (avatar)
+ * movement through the 3D world.
+ *
+ * <p>This handler is automatically registered as the default focus owner in the
+ * {@link KeyboardFocusStack}. It listens for arrow key presses on each frame and
+ * applies acceleration to the avatar's movement vector accordingly:</p>
+ * <ul>
+ *   <li><b>Up arrow</b> - move forward (positive Z)</li>
+ *   <li><b>Down arrow</b> - move backward (negative Z)</li>
+ *   <li><b>Right arrow</b> - move right (positive X)</li>
+ *   <li><b>Left arrow</b> - move left (negative X)</li>
+ * </ul>
+ *
+ * <p>Movement acceleration scales with the time delta between frames for smooth,
+ * frame-rate-independent navigation. It also scales with current speed for a natural
+ * acceleration curve.</p>
+ *
+ * @see KeyboardFocusStack the focus system that manages this handler
+ * @see Camera the camera/viewer that this handler moves
+ */
+public class WorldNavigationUserInputTracker implements KeyboardInputHandler, FrameListener {
+
+    /**
+     * Creates a new world navigation input tracker.
+     */
+    public WorldNavigationUserInputTracker() {
+    }
+
+    @Override
+    public boolean onFrame(final ViewPanel viewPanel,
+                                final int millisecondsSinceLastFrame) {
+
+        final InputManager inputManager = viewPanel.getInputManager();
+
+        final Camera camera = viewPanel.getCamera();
+
+        final double actualAcceleration = (long) millisecondsSinceLastFrame
+                * camera.cameraAcceleration
+                * (1 + (camera.getMovementSpeed() / 10));
+
+        if (inputManager.isKeyPressed(KeyboardHelper.UP))
+            camera.getMovementVector().z += actualAcceleration;
+
+        if (inputManager.isKeyPressed(KeyboardHelper.DOWN))
+            camera.getMovementVector().z -= actualAcceleration;
+
+        if (inputManager.isKeyPressed(KeyboardHelper.RIGHT))
+            camera.getMovementVector().x += actualAcceleration;
+
+        if (inputManager.isKeyPressed(KeyboardHelper.LEFT))
+            camera.getMovementVector().x -= actualAcceleration;
+
+        camera.enforceSpeedLimit();
+
+        return false;
+    }
+
+    @Override
+    public boolean focusLost(final ViewPanel viewPanel) {
+        viewPanel.removeFrameListener(this);
+        return false;
+    }
+
+    @Override
+    public boolean focusReceived(final ViewPanel viewPanel) {
+        viewPanel.addFrameListener(this);
+        return false;
+    }
+
+    @Override
+    public boolean keyPressed(final KeyEvent event, final ViewPanel viewContext) {
+        return false;
+    }
+
+    @Override
+    public boolean keyReleased(final KeyEvent event, final ViewPanel viewContext) {
+        return false;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/humaninput/package-info.java
new file mode 100644 (file)
index 0000000..041b06a
--- /dev/null
@@ -0,0 +1,7 @@
+/**
+ * Provides input device tracking (keyboard, mouse) and event forwarding to virtual components.
+ *
+ * @see eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager
+ * @see eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack
+ */
+package eu.svjatoslav.aukio.e3d.gui.humaninput;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/package-info.java
new file mode 100644 (file)
index 0000000..1dc70e4
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Graphical user interface components for the Aukio 3D engine.
+ *
+ * <p>This package provides the primary integration points for embedding 3D rendering
+ * into Java applications using Swing/AWT.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.gui.ViewPanel} - The main rendering surface (JPanel)</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.gui.ViewFrame} - A JFrame with embedded ViewPanel</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.gui.Camera} - Represents the viewer's position and orientation</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.gui.DeveloperTools} - Debugging and profiling utilities</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.gui.ViewPanel
+ * @see eu.svjatoslav.aukio.e3d.gui.Camera
+ */
+
+package eu.svjatoslav.aukio.e3d.gui;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java
new file mode 100644 (file)
index 0000000..89469c6
--- /dev/null
@@ -0,0 +1,161 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.spacemouse;
+
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.gui.FrameListener;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+/**
+ * Applies SpaceNavigator 6DOF input to the camera, every frame:
+ *
+ * <ul>
+ *   <li>push/pull/slide the cap → camera moves DIRECTLY, proportional
+ *       to deflection (the cap senses pressure, unlike a keyboard):
+ *       velocity = deflection × top speed, applied to the camera
+ *       position every frame. Release the cap and movement stops
+ *       instantly — no acceleration ramp, no coasting</li>
+ *   <li>tilt/twist the cap → camera yaw/pitch. Applied as an
+ *       incremental delta on top of the current orientation, so it
+ *       composes with head tracking (HeadLookController folds the
+ *       delta into its base pose, same as mouse drags) and with mouse
+ *       drag look</li>
+ *   <li>left button → brake (zero the keyboard/wheel movement vector)</li>
+ * </ul>
+ *
+ * <p>Cap roll is measured but deliberately not applied — consistent
+ * with head tracking, the world horizon stays level.</p>
+ */
+public final class SpaceMouseController implements FrameListener {
+
+    /**
+     * Raw-axis → camera sign mapping. Hardware conventions are
+     * documented in {@link SpaceNavigatorHid}. Camera conventions:
+     * movementVector.x+ = strafe right, .z+ = forward, .y+ = up;
+     * yaw+ = turn left, pitch+ = look up.
+     */
+    private static final double STRAFE_SIGN = 1.0;   // push right → strafe right
+    private static final double FORWARD_SIGN = -1.0; // push away → forward (live-verified)
+    private static final double VERTICAL_SIGN = 1.0; // press down → move down (live-verified)
+    private static final double YAW_SIGN = -1.0;     // twist clockwise → turn right
+    private static final double PITCH_SIGN = 1.0;    // tilt top away → look up (live-verified)
+
+    /** Raw counts below this are treated as "hands off". */
+    private static final int DEADBAND = 1;
+
+    /** Camera speed at full cap deflection, as a multiple of the
+     * keyboard top speed ({@link Camera#SPEED_LIMIT}). 3.0 = direct
+     * drive tops out at 3x the keyboard's capped speed (2026-09-10 —
+     * keyboard cap was the hidden ceiling behind "still too slow"). */
+    private static final double TRANSLATION_SPEED_FACTOR = 5.0;
+
+    /** Degrees of view yaw per frame at full cap twist
+     * (2x pitch 2026-09-10 — twist felt under-responsive). */
+    private static final double ROTATION_YAW_DEGREES = 3.0;
+
+    /** Degrees of view pitch per frame at full cap tilt. */
+    private static final double ROTATION_PITCH_DEGREES = 1.5;
+
+    private final SpaceNavigatorHid device;
+    private final ViewPanel viewPanel;
+
+    private double sensitivity = 5.0;
+    private boolean leftButtonWasPressed;
+
+    public SpaceMouseController(final SpaceNavigatorHid device,
+                                final ViewPanel viewPanel) {
+        this.device = device;
+        this.viewPanel = viewPanel;
+    }
+
+    public double getSensitivity() {
+        return sensitivity;
+    }
+
+    public void setSensitivity(final double sensitivity) {
+        this.sensitivity = sensitivity;
+    }
+
+    @Override
+    public boolean onFrame(final ViewPanel viewPanel,
+                           final int millisecondsSinceLastFrame) {
+        if (!device.isRunning())
+            return false;
+
+        final Camera camera = viewPanel.getCamera();
+        boolean changed = false;
+
+        // Frame-rate independent scaling, 60 fps baseline.
+        final double dt = millisecondsSinceLastFrame / 16.6667;
+
+        final double strafe = shaped(device.getTx()) * STRAFE_SIGN;
+        final double forward = shaped(device.getTy()) * FORWARD_SIGN;
+        final double vertical = shaped(device.getTz()) * VERTICAL_SIGN;
+        if (strafe != 0 || forward != 0 || vertical != 0) {
+            // Direct proportional drive: deflection maps to velocity
+            // (full deflection = keyboard top speed), applied straight
+            // to the camera position. No movement vector, no friction —
+            // releasing the cap stops the camera this very frame.
+            final Matrix3x3 m = camera.getTransform().getRotation()
+                    .toMatrix();
+            final Point3D location = camera.getTransform()
+                    .getTranslation();
+            final double step = TRANSLATION_SPEED_FACTOR
+                    * Camera.SPEED_LIMIT * Camera.SPEED_MULTIPLIER
+                    * millisecondsSinceLastFrame * sensitivity;
+            location.x += (m.m20 * forward + m.m00 * strafe) * step;
+            location.y += (m.m21 * forward + m.m01 * strafe) * step;
+            location.z += (m.m22 * forward + m.m02 * strafe) * step;
+            location.y += vertical * step;
+            changed = true;
+        }
+
+        final double yawDelta = shaped(device.getRz()) * YAW_SIGN;
+        final double pitchDelta = shaped(device.getRx()) * PITCH_SIGN;
+        if (yawDelta != 0 || pitchDelta != 0) {
+            final double[] angles = camera.getTransform().getRotation()
+                    .toAngles();
+            double yaw = angles[0] + yawDelta
+                    * Math.toRadians(ROTATION_YAW_DEGREES)
+                    * sensitivity * dt;
+            double pitch = angles[1] + pitchDelta
+                    * Math.toRadians(ROTATION_PITCH_DEGREES)
+                    * sensitivity * dt;
+            pitch = Math.max(-Math.PI / 2 + 0.001,
+                    Math.min(Math.PI / 2 - 0.001, pitch));
+            camera.getTransform().getRotation().set(
+                    Quaternion.fromAngles(yaw, pitch, 0));
+            changed = true;
+        }
+
+        final boolean leftPressed = (device.getButtons() & 1) != 0;
+        if (leftPressed && !leftButtonWasPressed) {
+            camera.getMovementVector().x = 0;
+            camera.getMovementVector().y = 0;
+            camera.getMovementVector().z = 0;
+            changed = true;
+        }
+        leftButtonWasPressed = leftPressed;
+
+        return changed;
+    }
+
+    /**
+     * Deadband + quadratic response: raw axis (−350..350) → −1..1.
+     * Quadratic keeps fine control near the center while preserving
+     * full speed at the rim.
+     */
+    private static double shaped(final int raw) {
+        final int magnitude = Math.abs(raw);
+        if (magnitude < DEADBAND)
+            return 0;
+        final double v = (magnitude - DEADBAND)
+                / (SpaceNavigatorHid.FULL_DEFLECTION - DEADBAND);
+        return Math.copySign(v * v, raw);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java
new file mode 100644 (file)
index 0000000..68f0d0e
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.spacemouse;
+
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+
+/**
+ * Hot-plug manager for the SpaceNavigator 6DOF mouse. Polls for the
+ * device every two seconds:
+ *
+ * <ul>
+ *   <li>plugged in → open the HID pipe, attach a
+ *       {@link SpaceMouseController} to the view's frame listeners</li>
+ *   <li>unplugged (read error) → stop and detach</li>
+ *   <li>plugged back in → fresh device instance</li>
+ * </ul>
+ *
+ * <p>Permission failures are reported once, then retried silently —
+ * the user may install the udev rule while the application runs.</p>
+ */
+public final class SpaceMouseManager {
+
+    private static final long POLL_INTERVAL_MS = 2000;
+
+    private final ViewPanel viewPanel;
+    private final Thread thread;
+
+    private volatile boolean running = true;
+    private SpaceNavigatorHid device;
+    private SpaceMouseController controller;
+    private boolean failureReported;
+
+    public SpaceMouseManager(final ViewPanel viewPanel) {
+        this.viewPanel = viewPanel;
+        thread = new Thread(this::pollLoop, "spacemouse-hotplug");
+        thread.setDaemon(true);
+    }
+
+    public void start() {
+        thread.start();
+    }
+
+    /** Active device, or null when no SpaceNavigator is connected. */
+    public SpaceNavigatorHid getDevice() {
+        return device;
+    }
+
+    /** Active controller, or null when no SpaceNavigator is connected. */
+    public SpaceMouseController getController() {
+        return controller;
+    }
+
+    public void stop() {
+        running = false;
+        try {
+            thread.join(1000);
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+        disconnect();
+    }
+
+    private void pollLoop() {
+        while (running) {
+            poll();
+            try {
+                Thread.sleep(POLL_INTERVAL_MS);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                return;
+            }
+        }
+    }
+
+    private void poll() {
+        if (device != null && !device.isRunning()) {
+            System.out.println("spacemouse: disconnected");
+            disconnect();
+        }
+        if (device != null)
+            return;
+
+        try {
+            final SpaceNavigatorHid opened = SpaceNavigatorHid.open();
+            if (opened == null)
+                return;
+            opened.start();
+            device = opened;
+            controller = new SpaceMouseController(opened, viewPanel);
+            viewPanel.addFrameListener(controller);
+            failureReported = false;
+            System.out.println("spacemouse: SpaceNavigator detected — "
+                    + "cap moves the camera, left button brakes");
+        } catch (final Exception e) {
+            device = null;
+            if (!failureReported) {
+                failureReported = true;
+                System.err.println("spacemouse unavailable: "
+                        + e.getMessage());
+            }
+        }
+    }
+
+    private void disconnect() {
+        if (controller != null) {
+            viewPanel.removeFrameListener(controller);
+            controller = null;
+        }
+        if (device != null) {
+            device.stop();
+            device = null;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java
new file mode 100644 (file)
index 0000000..9976b84
--- /dev/null
@@ -0,0 +1,186 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.spacemouse;
+
+import com.sun.jna.Library;
+import com.sun.jna.Memory;
+import com.sun.jna.Native;
+
+import java.io.File;
+
+/**
+ * HID transport for the 3Dconnexion SpaceNavigator 6DOF mouse
+ * (USB 046D:C626), read via the Linux hidraw interface using JNA —
+ * same approach as the RayNeo glasses transport.
+ *
+ * <p>Protocol (from libspnav/spacenavd documentation): the device
+ * sends 7-byte input reports, ONLY while the cap is deflected or a
+ * button changes — at rest the pipe is silent (so unlike the XR
+ * glasses there is no stream watchdog; silence is normal):</p>
+ *
+ * <ul>
+ *   <li>report[0] = 1 — translation; int16 LE at [1,2]=X, [3,4]=Y,
+ *       [5,6]=Z; full deflection ≈ ±350</li>
+ *   <li>report[0] = 2 — rotation; int16 LE at [1,2]=RX, [3,4]=RY,
+ *       [5,6]=RZ; full deflection ≈ ±350</li>
+ *   <li>report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right</li>
+ * </ul>
+ *
+ * <p>Raw axis sign conventions (SpaceNavigator hardware):</p>
+ * <ul>
+ *   <li>TX: push cap right → positive</li>
+ *   <li>TY: pull cap toward you → positive (live-verified)</li>
+ *   <li>TZ: pull cap up → positive (live-verified)</li>
+ *   <li>RX: tilt cap top away → positive</li>
+ *   <li>RY: tilt cap top right → positive</li>
+ *   <li>RZ: twist cap clockwise (seen from above) → positive</li>
+ * </ul>
+ *
+ * <p>Detection scans /sys/class/hidraw for HID_ID
+ * 0003:0000046D:0000C626 — the hidraw node changes on replug, so never
+ * cache the path. Unplug is detected by a read error (read returns
+ * &lt;0).</p>
+ */
+public final class SpaceNavigatorHid {
+
+    private static final String HID_ID = "0003:0000046D:0000C626";
+
+    /** Full deflection of any axis, per the HID descriptor. */
+    public static final double FULL_DEFLECTION = 350.0;
+
+    /**
+     * Same JNA calling convention as the proven RayNeo glasses
+     * transport: static singleton mapping, Memory buffers (a raw
+     * byte[] mapping correlated with native heap corruption —
+     * "free(): invalid pointer" — under active report streaming).
+     */
+    private interface CLib extends Library {
+        CLib INSTANCE = Native.load("c", CLib.class);
+
+        int open(String path, int flags);
+
+        int close(int fd);
+
+        int read(int fd, Memory buffer, int count);
+    }
+
+    private final File deviceNode;
+    private final Memory readBuffer = new Memory(64);
+
+    private int fd = -1;
+    private Thread reader;
+    private volatile boolean running;
+
+    /** Latest axis state, in raw device units (±350). */
+    private volatile int tx, ty, tz, rx, ry, rz;
+    private volatile int buttons;
+    /** Set when a read error (typically unplug) killed the reader. */
+    private volatile boolean broken;
+
+    private SpaceNavigatorHid(final File deviceNode) {
+        this.deviceNode = deviceNode;
+    }
+
+    /**
+     * Finds and opens the first SpaceNavigator on the system, or null
+     * when none is plugged in. Throws when the device is present but
+     * cannot be opened (permissions — see
+     * /etc/udev/rules.d/99-spacenavigator.rules).
+     */
+    public static SpaceNavigatorHid open() {
+        final File hidrawDir = new File("/sys/class/hidraw");
+        final File[] entries = hidrawDir.listFiles();
+        if (entries == null)
+            return null;
+        for (final File entry : entries) {
+            final File uevent = new File(entry, "device/uevent");
+            if (!uevent.isFile())
+                continue;
+            try {
+                final String content = new String(
+                        java.nio.file.Files.readAllBytes(uevent.toPath()));
+                if (!content.contains(HID_ID))
+                    continue;
+                final File node = new File("/dev", entry.getName());
+                final SpaceNavigatorHid hid = new SpaceNavigatorHid(node);
+                hid.openNode();
+                return hid;
+            } catch (final Exception e) {
+                throw new RuntimeException("SpaceNavigator found at "
+                        + entry.getName() + " but cannot be opened: "
+                        + e.getMessage());
+            }
+        }
+        return null;
+    }
+
+    private void openNode() {
+        fd = CLib.INSTANCE.open(deviceNode.getAbsolutePath(),
+                2 /* O_RDWR */);
+        if (fd < 0)
+            throw new RuntimeException("open(" + deviceNode
+                    + ") failed: " + Native.getLastError());
+    }
+
+    public void start() {
+        running = true;
+        reader = new Thread(this::readLoop, "spacenavigator-hid");
+        reader.setDaemon(true);
+        reader.start();
+    }
+
+    private void readLoop() {
+        while (running) {
+            final int n = CLib.INSTANCE.read(fd, readBuffer, 64);
+            if (n < 0) {
+                broken = true;  // unplugged
+                return;
+            }
+            if (n < 7)
+                continue;
+            final int reportId = readBuffer.getByte(0) & 0xFF;
+            if (reportId == 1) {
+                tx = readBuffer.getShort(1);
+                ty = readBuffer.getShort(3);
+                tz = readBuffer.getShort(5);
+            } else if (reportId == 2) {
+                rx = readBuffer.getShort(1);
+                ry = readBuffer.getShort(3);
+                rz = readBuffer.getShort(5);
+            } else if (reportId == 3) {
+                buttons = readBuffer.getByte(1) & 0xFF;
+            }
+        }
+    }
+
+    /** True while the device is connected and the reader is alive. */
+    public boolean isRunning() {
+        return running && !broken;
+    }
+
+    public int getTx() { return tx; }
+    public int getTy() { return ty; }
+    public int getTz() { return tz; }
+    public int getRx() { return rx; }
+    public int getRy() { return ry; }
+    public int getRz() { return rz; }
+    public int getButtons() { return buttons; }
+
+    public void stop() {
+        running = false;
+        if (fd >= 0) {
+            CLib.INSTANCE.close(fd);
+            fd = -1;
+        }
+        if (reader != null) {
+            try {
+                reader.join(1000);
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            reader = null;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Character.java
new file mode 100644 (file)
index 0000000..5d86f67
--- /dev/null
@@ -0,0 +1,29 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+/**
+ * A character in a text editor.
+ */
+public class Character {
+
+    /**
+     * The character value.
+     */
+    char value;
+
+    /**
+     * Creates a character with the given value.
+     *
+     * @param value the character value
+     */
+    public Character(final char value) {
+        this.value = value;
+    }
+
+    boolean hasValue() {
+        return value != ' ';
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/LookAndFeel.java
new file mode 100644 (file)
index 0000000..aebf041
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * A look and feel of a text editor.
+ */
+public class LookAndFeel {
+
+    /** Default foreground (text) color. */
+    public Color foreground = new Color(255, 255, 255);
+
+    /** Default background color. */
+    public Color background = new Color(20, 20, 20, 255);
+
+    /** Background color for tab stop positions. */
+    public Color tabStopBackground = new Color(25, 25, 25, 255);
+
+    /** Cursor foreground color. */
+    public Color cursorForeground = new Color(255, 255, 255);
+
+    /** Cursor background color. */
+    public Color cursorBackground = new Color(255, 0, 0);
+
+    /** Selection foreground color. */
+    public Color selectionForeground = new Color(255, 255, 255);
+
+    /** Selection background color. */
+    public Color selectionBackground = new Color(0, 80, 80);
+
+    /**
+     * Creates a look and feel with default colors.
+     */
+    public LookAndFeel() {
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/Page.java
new file mode 100644 (file)
index 0000000..06e94b8
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A page in a text editor.
+ */
+public class Page {
+
+    /**
+     * The text lines.
+     */
+    public List<TextLine> rows = new ArrayList<>();
+
+    /**
+     * Creates a new empty page.
+     */
+    public Page() {
+    }
+
+    /**
+     * Ensures that the page has at least the specified number of lines.
+     *
+     * @param row the minimum number of lines required
+     */
+    public void ensureMaxTextLine(final int row) {
+        while (rows.size() <= row)
+            rows.add(new TextLine());
+    }
+
+    /**
+     * Returns the character at the specified location.
+     * If the location is out of bounds, returns a space.
+     *
+     * @param row    the row index
+     * @param column the column index
+     * @return the character at the specified location
+     */
+    public char getChar(final int row, final int column) {
+        if (rows.size() <= row)
+            return ' ';
+        return rows.get(row).getCharForLocation(column);
+    }
+
+    /**
+     * Returns the specified line.
+     *
+     * @param row The line number.
+     * @return The line.
+     */
+    public TextLine getLine(final int row) {
+        ensureMaxTextLine(row);
+        return rows.get(row);
+    }
+
+    /**
+     * Returns the length of the specified line.
+     *
+     * @param row The line number.
+     * @return The length of the line.
+     */
+    public int getLineLength(final int row) {
+        if (rows.size() <= row)
+            return 0;
+        return rows.get(row).getLength();
+    }
+
+    /**
+     * Returns the number of lines in the page.
+     *
+     * @return The number of lines in the page.
+     */
+    public int getLinesCount() {
+        pack();
+        return rows.size();
+    }
+
+    /**
+     * Returns the text of the page.
+     *
+     * @return The text of the page.
+     */
+    public String getText() {
+        pack();
+
+        final StringBuilder result = new StringBuilder();
+        for (final TextLine textLine : rows) {
+            if (result.length() > 0)
+                result.append("\n");
+            result.append(textLine.toString());
+        }
+        return result.toString();
+    }
+
+    /**
+     * Inserts a character at the specified position.
+     *
+     * @param row   the row index
+     * @param col   the column index
+     * @param value the character to insert
+     */
+    public void insertCharacter(final int row, final int col, final char value) {
+        getLine(row).insertCharacter(col, value);
+    }
+
+    /**
+     * Inserts a line at the specified row.
+     *
+     * @param row      the row index where to insert
+     * @param textLine the text line to insert
+     */
+    public void insertLine(final int row, final TextLine textLine) {
+        rows.add(row, textLine);
+    }
+
+    /**
+     * Removes empty lines from the end of the page.
+     */
+    private void pack() {
+        int newLength = 0;
+
+        for (int i = rows.size() - 1; i >= 0; i--)
+            if (!rows.get(i).isEmpty()) {
+                newLength = i + 1;
+                break;
+            }
+
+        if (newLength == rows.size())
+            return;
+
+        rows = rows.subList(0, newLength);
+    }
+
+    /**
+     * Removes the specified character from the page.
+     *
+     * @param row The line number.
+     * @param col The character number.
+     */
+    public void removeCharacter(final int row, final int col) {
+        if (rows.size() <= row)
+            return;
+        getLine(row).removeCharacter(col);
+    }
+
+    /**
+     * Removes the specified line from the page.
+     *
+     * @param row The line number.
+     */
+    public void removeLine(final int row) {
+        if (rows.size() <= row)
+            return;
+        rows.remove(row);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextEditComponent.java
new file mode 100755 (executable)
index 0000000..8e62ff5
--- /dev/null
@@ -0,0 +1,915 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.GuiComponent;
+import eu.svjatoslav.aukio.e3d.gui.TextPointer;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardHelper;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+
+import java.awt.*;
+import java.awt.datatransfer.*;
+import java.awt.event.KeyEvent;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A full-featured text editor component rendered in 3D space.
+ *
+ * <p>Extends {@link GuiComponent} to integrate keyboard focus management and mouse
+ * interaction with a multi-line text editing surface. The editor is backed by a
+ * {@link Page} model containing {@link TextLine} instances and rendered via a
+ * {@link TextCanvas}.</p>
+ *
+ * <p><b>Supported editing features:</b></p>
+ * <ul>
+ *   <li>Cursor navigation with arrow keys, Home, End, Page Up, and Page Down</li>
+ *   <li>Text selection via Shift + arrow keys</li>
+ *   <li>Clipboard operations: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste), Ctrl+A (select all)</li>
+ *   <li>Word-level cursor movement with Ctrl+Left and Ctrl+Right</li>
+ *   <li>Tab indentation and Shift+Tab dedentation for single lines and block selections</li>
+ *   <li>Backspace dedentation of selected blocks (removes 4 spaces of indentation)</li>
+ *   <li>Automatic scrolling when the cursor moves beyond the visible area</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a look and feel (or use defaults)
+ * LookAndFeel lookAndFeel = new LookAndFeel();
+ *
+ * // Create the text editor at a position in 3D space
+ * TextEditComponent editor = new TextEditComponent(
+ *     new Transform(new Point3D(0, 0, 500)),  // position in world
+ *     viewPanel,                                // the active ViewPanel
+ *     new Point2D(800, 600),                    // size in world coordinates
+ *     lookAndFeel
+ * );
+ *
+ * // Set initial content
+ * editor.setText("Hello, World!\nSecond line of text.");
+ *
+ * // Add to the scene
+ * viewPanel.getRootShapeCollection().addShape(editor);
+ * }</pre>
+ *
+ * @see GuiComponent the base class providing keyboard focus and mouse click handling
+ * @see Page the underlying text model holding all lines
+ * @see TextCanvas the rendering surface for character-based output
+ * @see LookAndFeel configurable colors for the editor's visual appearance
+ * @see TextPointer row/column pointer used for cursor and selection positions
+ */
+public class TextEditComponent extends GuiComponent implements ClipboardOwner {
+
+    private static final long serialVersionUID = -7118833957783600630L;
+
+    /**
+     * Text rows that need to be repainted.
+     */
+    private final Set<Integer> dirtyRows = new HashSet<>();
+
+
+    /**
+     * The text canvas used to render characters on screen.
+     */
+    private final TextCanvas textCanvas;
+
+    /**
+     * The number of characters the view is scrolled horizontally.
+     */
+    public int scrolledCharacters = 0;
+
+    /**
+     * The number of lines the view is scrolled vertically.
+     */
+    public int scrolledLines = 0;
+
+    /**
+     * Whether the user is currently in selection mode (Shift key held during navigation).
+     */
+    public boolean selecting = false;
+
+    /**
+     * Selection start and end pointers.
+     */
+    public TextPointer selectionStart = new TextPointer(0, 0);
+
+    /**
+     * The end position of the text selection.
+     */
+    public TextPointer selectionEnd = new TextPointer(0, 0);
+
+    /**
+     * The current cursor position in the text (row and column).
+     */
+    public TextPointer cursorLocation = new TextPointer(0, 0);
+
+    /**
+     * The page model holding all text lines.
+     */
+    Page page = new Page();
+
+    /**
+     * The look and feel configuration controlling editor colors.
+     */
+    LookAndFeel lookAndFeel;
+
+    /**
+     * If true, the page will be repainted on the next update.
+     */
+    boolean repaintPage = false;
+
+    /**
+     * Creates a new text editor component positioned in 3D space.
+     *
+     * <p>The editor dimensions in rows and columns are computed from the given world-coordinate
+     * size and the font character dimensions defined in {@link TextCanvas}. A {@link TextCanvas}
+     * is created internally and added as a child shape.</p>
+     *
+     * @param transform              the position and orientation of the editor in 3D space
+     * @param viewPanel              the view panel this editor belongs to
+     * @param sizeInWorldCoordinates the editor size in world coordinates (width, height);
+     *                               determines the number of visible columns and rows
+     * @param lookAndFeel            the color configuration for the editor's visual appearance
+     */
+    public TextEditComponent(final Transform transform,
+                             final ViewPanel viewPanel,
+                             final Point2D sizeInWorldCoordinates,
+                             LookAndFeel lookAndFeel) {
+        super(transform, viewPanel, sizeInWorldCoordinates.to3D());
+
+        this.lookAndFeel = lookAndFeel;
+        final int columns = (int) (sizeInWorldCoordinates.x / TextCanvas.FONT_CHAR_WIDTH);
+        final int rows = (int) (sizeInWorldCoordinates.y / TextCanvas.FONT_CHAR_HEIGHT);
+
+        textCanvas = new TextCanvas(
+                new Transform(),
+                new TextPointer(rows, columns),
+                lookAndFeel.foreground, lookAndFeel.background);
+
+        textCanvas.setMouseInteractionController(this);
+
+        repaintPage();
+        addShape(textCanvas);
+    }
+
+    /**
+     * Ensures the cursor stays within the visible editor area by adjusting
+     * scroll offsets when the cursor moves beyond the visible boundaries.
+     * Also clamps the cursor position so that row and column are never negative.
+     */
+    private void checkCursorBoundaries() {
+        if (cursorLocation.column < 0)
+            cursorLocation.column = 0;
+        if (cursorLocation.row < 0)
+            cursorLocation.row = 0;
+
+        // ensure chat cursor stays within vertical editor boundaries by
+        // vertical scrolling
+        if ((cursorLocation.row - scrolledLines) < 0)
+            scroll(0, cursorLocation.row - scrolledLines);
+
+        if ((((cursorLocation.row - scrolledLines) + 1)) > textCanvas.getSize().row)
+            scroll(0,
+                    ((((((cursorLocation.row - scrolledLines) + 1) - textCanvas
+                            .getSize().row)))));
+
+        // ensure chat cursor stays within horizontal editor boundaries by
+        // horizontal scrolling
+        if ((cursorLocation.column - scrolledCharacters) < 0)
+            scroll(cursorLocation.column - scrolledCharacters, 0);
+
+        if ((((cursorLocation.column - scrolledCharacters) + 1)) > textCanvas
+                .getSize().column)
+            scroll((((((cursorLocation.column - scrolledCharacters) + 1) - textCanvas
+                    .getSize().column))), 0);
+    }
+
+    /**
+     * Clears the current text selection by setting the selection end to match
+     * the selection start, effectively making the selection empty.
+     *
+     * <p>A full page repaint is scheduled to remove the visual selection highlight.</p>
+     */
+    public void clearSelection() {
+        selectionEnd = new TextPointer(selectionStart);
+        repaintPage = true;
+    }
+
+    /**
+     * Copies the currently selected text to the system clipboard.
+     *
+     * <p>If no text is selected (i.e., selection start equals selection end),
+     * this method does nothing. Multi-line selections are joined with newline
+     * characters.</p>
+     *
+     * @see #setClipboardContents(String)
+     * @see #cutToClipboard()
+     */
+    public void copyToClipboard() {
+        if (selectionStart.compareTo(selectionEnd) == 0)
+            return;
+        // System.out.println("Copy action.");
+        final StringBuilder msg = new StringBuilder();
+
+        ensureSelectionOrder();
+
+        for (int row = selectionStart.row; row <= selectionEnd.row; row++) {
+            final TextLine textLine = page.getLine(row);
+
+            if (row == selectionStart.row) {
+                if (row == selectionEnd.row)
+                    msg.append(textLine.getSubString(selectionStart.column,
+                            selectionEnd.column + 1));
+                else
+                    msg.append(textLine.getSubString(selectionStart.column,
+                            textLine.getLength()));
+            } else {
+                msg.append('\n');
+                if (row == selectionEnd.row)
+                    msg.append(textLine
+                            .getSubString(0, selectionEnd.column + 1));
+                else
+                    msg.append(textLine.toString());
+            }
+        }
+
+        setClipboardContents(msg.toString());
+    }
+
+    /**
+     * Cuts the currently selected text to the system clipboard.
+     *
+     * <p>This copies the selected text to the clipboard via {@link #copyToClipboard()},
+     * then deletes the selection from the page and triggers a full repaint.</p>
+     *
+     * @see #copyToClipboard()
+     * @see #deleteSelection()
+     */
+    public void cutToClipboard() {
+        copyToClipboard();
+        deleteSelection();
+        repaintPage();
+    }
+
+    /**
+     * Deletes the currently selected text from the page.
+     *
+     * <p>After deletion, the selection is cleared and the cursor is moved to
+     * the position where the selection started.</p>
+     *
+     * @see #ensureSelectionOrder()
+     */
+    public void deleteSelection() {
+        ensureSelectionOrder();
+        int ym = 0;
+
+        for (int line = selectionStart.row; line <= selectionEnd.row; line++) {
+            final TextLine currentLine = page.getLine(line - ym);
+
+            if (line == selectionStart.row) {
+                if (line == selectionEnd.row)
+
+                    currentLine.cutSubString(selectionStart.column,
+                            selectionEnd.column);
+                else if (selectionStart.column == 0) {
+                    page.removeLine(line - ym);
+                    ym++;
+                } else
+                    currentLine.cutSubString(selectionStart.column,
+                            currentLine.getLength() + 1);
+            } else if (line == selectionEnd.row)
+                currentLine.cutSubString(0, selectionEnd.column);
+            else {
+                page.removeLine(line - ym);
+                ym++;
+            }
+        }
+
+        clearSelection();
+        cursorLocation = new TextPointer(selectionStart);
+    }
+
+    /**
+     * Ensures that {@link #selectionStart} is smaller than
+     * {@link #selectionEnd}.
+     *
+     * <p>If the start pointer is after the end pointer (e.g., when the user
+     * selected text backwards), the two pointers are swapped so that
+     * subsequent operations can iterate from start to end.</p>
+     */
+    public void ensureSelectionOrder() {
+        if (selectionStart.compareTo(selectionEnd) > 0) {
+            final TextPointer temp = selectionEnd;
+            selectionEnd = selectionStart;
+            selectionStart = temp;
+        }
+    }
+
+    /**
+     * Retrieves the current text contents of the system clipboard.
+     *
+     * @return the clipboard text content, or an empty string if the clipboard
+     *         is empty or does not contain text
+     */
+    public String getClipboardContents() {
+        String result = "";
+        final Clipboard clipboard = Toolkit.getDefaultToolkit()
+                .getSystemClipboard();
+        // odd: the Object param of getContents is not currently used
+        final Transferable contents = clipboard.getContents(null);
+        final boolean hasTransferableText = (contents != null)
+                && contents.isDataFlavorSupported(DataFlavor.stringFlavor);
+        if (hasTransferableText)
+            try {
+                result = (String) contents
+                        .getTransferData(DataFlavor.stringFlavor);
+            } catch (final UnsupportedFlavorException | IOException ex) {
+                // highly unlikely since we are using a standard DataFlavor
+                System.out.println(ex);
+            }
+        // System.out.println(result);
+        return result;
+    }
+
+    /**
+     * Places the given string into the system clipboard so that it can be
+     * pasted into other applications.
+     *
+     * @param contents the text to place on the clipboard
+     * @see #getClipboardContents()
+     * @see #copyToClipboard()
+     */
+    public void setClipboardContents(final String contents) {
+        final StringSelection stringSelection = new StringSelection(contents);
+        final Clipboard clipboard = Toolkit.getDefaultToolkit()
+                .getSystemClipboard();
+        clipboard.setContents(stringSelection, stringSelection);
+    }
+
+    /**
+     * Scrolls to and positions the cursor at the beginning of the specified line.
+     *
+     * <p>The view is scrolled so the target line is visible, the cursor is placed
+     * at the start of that line (column 0), and a full repaint is triggered.</p>
+     *
+     * @param Line the zero-based line number to navigate to
+     */
+    public void goToLine(final int Line) {
+        // markNavigationLocation(Line);
+        scrolledLines = Line + 1;
+        cursorLocation.row = Line + 1;
+        cursorLocation.column = 0;
+        repaintPage();
+    }
+
+    /**
+     * Inserts the given text string at the current cursor position.
+     *
+     * <p>The text is processed character by character. Special characters are
+     * handled as editing operations:</p>
+     * <ul>
+     *   <li>{@code DEL} -- deletes the character at the cursor</li>
+     *   <li>{@code ENTER} -- splits the current line at the cursor</li>
+     *   <li>{@code BACKSPACE} -- deletes the character before the cursor</li>
+     * </ul>
+     * <p>All other printable characters are inserted at the cursor position,
+     * advancing the cursor column by one for each character.</p>
+     *
+     * @param txt the text to insert; {@code null} values are silently ignored
+     */
+    public void insertText(final String txt) {
+        if (txt == null)
+            return;
+
+        for (final char c : txt.toCharArray()) {
+
+            if (c == KeyboardHelper.DEL) {
+                processDel();
+                continue;
+            }
+
+            if (c == KeyboardHelper.ENTER) {
+                processEnter();
+                continue;
+            }
+
+            if (c == KeyboardHelper.BACKSPACE) {
+                processBackspace();
+                continue;
+            }
+
+            // type character
+            if (KeyboardHelper.isText(c)) {
+                page.insertCharacter(cursorLocation.row, cursorLocation.column,
+                        c);
+                cursorLocation.column++;
+            }
+        }
+    }
+
+    /**
+     * Handles a key press event by routing it through the editor's input processing
+     * pipeline.
+     *
+     * <p>This method delegates to the parent {@link GuiComponent#keyPressed(KeyEvent, ViewPanel)}
+     * (which handles ESC for focus release), then processes the key event for text editing,
+     * marks the affected row as dirty, adjusts scroll boundaries, and repaints as needed.</p>
+     *
+     * @param event     the keyboard event
+     * @param viewPanel the view panel that dispatched this event
+     * @return always {@code true}, indicating the event was consumed
+     */
+    @Override
+    public boolean keyPressed(final KeyEvent event, final ViewPanel viewPanel) {
+        super.keyPressed(event, viewPanel);
+
+        processKeyEvent(event);
+
+        markRowDirty();
+
+        checkCursorBoundaries();
+
+        repaintWhatNeeded();
+        return true;
+    }
+
+    /**
+     * Called when this editor loses ownership of the system clipboard.
+     *
+     * <p>This is an empty implementation of the {@link ClipboardOwner} interface;
+     * no action is taken when clipboard ownership is lost.</p>
+     *
+     * @param aClipboard the clipboard that this editor previously owned
+     * @param aContents  the contents that were previously placed on the clipboard
+     */
+    @Override
+    public void lostOwnership(final Clipboard aClipboard,
+                              final Transferable aContents) {
+        // do nothing
+    }
+
+    /**
+     * Marks the current cursor row as dirty, scheduling it for repaint on the
+     * next rendering cycle.
+     */
+    public void markRowDirty() {
+        dirtyRows.add(cursorLocation.row);
+    }
+
+    /**
+     * Pastes text from the system clipboard at the current cursor position.
+     *
+     * @see #getClipboardContents()
+     * @see #insertText(String)
+     */
+    public void pasteFromClipboard() {
+        insertText(getClipboardContents());
+    }
+
+    /**
+     * Processes the backspace key action.
+     *
+     * <p>If there is no active selection, deletes the character before the cursor.
+     * If the cursor is at the beginning of a line, merges the current line with the
+     * previous one. If there is an active selection, dedents the selected lines by
+     * removing up to 4 leading spaces (block dedentation).</p>
+     */
+    private void processBackspace() {
+        if (selectionStart.compareTo(selectionEnd) == 0) {
+            // erase single character
+            if (cursorLocation.column > 0) {
+                cursorLocation.column--;
+                page.removeCharacter(cursorLocation.row, cursorLocation.column);
+                // System.out.println(lines.get(currentCursor.line).toString());
+            } else if (cursorLocation.row > 0) {
+                cursorLocation.row--;
+                final int currentLineLength = page
+                        .getLineLength(cursorLocation.row);
+                cursorLocation.column = currentLineLength;
+                page.getLine(cursorLocation.row)
+                        .insertTextLine(currentLineLength,
+                                page.getLine(cursorLocation.row + 1));
+                page.removeLine(cursorLocation.row + 1);
+                repaintPage = true;
+            }
+        } else {
+            // dedent multiple lines
+            ensureSelectionOrder();
+            // scan if enough space exists
+            for (int y = selectionStart.row; y < selectionEnd.row; y++)
+                if (page.getLine(y).getIndent() < 4)
+                    return;
+
+            for (int y = selectionStart.row; y < selectionEnd.row; y++)
+                page.getLine(y).cutFromBeginning(4);
+
+            repaintPage = true;
+        }
+    }
+
+    /**
+     * Processes keyboard shortcuts involving the Ctrl modifier key.
+     *
+     * <p>Supported combinations:</p>
+     * <ul>
+     *   <li>Ctrl+A -- select all text</li>
+     *   <li>Ctrl+X -- cut selected text to clipboard</li>
+     *   <li>Ctrl+C -- copy selected text to clipboard</li>
+     *   <li>Ctrl+V -- paste from clipboard</li>
+     *   <li>Ctrl+Right -- skip to the beginning of the next word</li>
+     *   <li>Ctrl+Left -- skip to the beginning of the previous word</li>
+     * </ul>
+     *
+     * @param keyCode the key code of the pressed key (combined with Ctrl)
+     */
+    private void processCtrlCombinations(final int keyCode) {
+
+        if ((char) keyCode == 'A') { // CTRL + A -- select all
+            final int lastLineIndex = page.getLinesCount() - 1;
+            selectionStart = new TextPointer(0, 0);
+            selectionEnd = new TextPointer(lastLineIndex,
+                    page.getLineLength(lastLineIndex));
+            repaintPage();
+        }
+
+        // CTRL + X -- cut
+        if ((char) keyCode == 'X')
+            cutToClipboard();
+
+        // CTRL + C -- copy
+        if ((char) keyCode == 'C')
+            copyToClipboard();
+
+        // CTRL + V -- paste
+        if ((char) keyCode == 'V')
+            pasteFromClipboard();
+
+        if (keyCode == 39) { // RIGHT
+            // skip to the beginning of the next word
+
+            for (int x = cursorLocation.column; x < (page
+                    .getLineLength(cursorLocation.row) - 1); x++)
+                if ((page.getChar(cursorLocation.row, x) == ' ')
+                        && (page.getChar(cursorLocation.row, x + 1) != ' ')) {
+                    // beginning of the next word is found
+                    cursorLocation.column = x + 1;
+                    return;
+                }
+
+            cursorLocation.column = page.getLineLength(cursorLocation.row);
+            return;
+        }
+
+        if (keyCode == 37) { // Left
+
+            // skip to the beginning of the previous word
+            for (int x = cursorLocation.column - 2; x >= 0; x--)
+                if ((page.getChar(cursorLocation.row, x) == ' ')
+                        & (page.getChar(cursorLocation.row, x + 1) != ' ')) {
+                    cursorLocation.column = x + 1;
+                    return;
+                }
+
+            cursorLocation.column = 0;
+        }
+    }
+
+    /**
+     * Processes the Delete key action.
+     *
+     * <p>If there is no active selection, deletes the character at the cursor position.
+     * If the cursor is at the end of the line, the next line is merged into the current one.
+     * If there is an active selection, the entire selection is deleted.</p>
+     */
+    public void processDel() {
+        if (selectionStart.compareTo(selectionEnd) == 0) {
+            // is there still some text right to the cursor ?
+            if (cursorLocation.column < page.getLineLength(cursorLocation.row))
+                page.removeCharacter(cursorLocation.row, cursorLocation.column);
+            else {
+                page.getLine(cursorLocation.row).insertTextLine(
+                        cursorLocation.column,
+                        page.getLine(cursorLocation.row + 1));
+                page.removeLine(cursorLocation.row + 1);
+                repaintPage = true;
+            }
+        } else {
+            deleteSelection();
+            repaintPage = true;
+        }
+    }
+
+    /**
+     * Processes the Enter key action by splitting the current line at the cursor position.
+     *
+     * <p>Everything to the right of the cursor is moved to a new line inserted
+     * below. The cursor moves to the beginning of the new line.</p>
+     */
+    private void processEnter() {
+        final TextLine currentLine = page.getLine(cursorLocation.row);
+        // move everything right to the cursor into new line
+        final TextLine newLine = currentLine.getSubLine(cursorLocation.column,
+                currentLine.getLength());
+        page.insertLine(cursorLocation.row + 1, newLine);
+
+        // trim existing line
+        page.getLine(cursorLocation.row).cutUntilEnd(cursorLocation.column);
+        repaintPage = true;
+
+        cursorLocation.row++;
+        cursorLocation.column = 0;
+    }
+
+    /**
+     * Routes a keyboard event to the appropriate handler based on modifier keys
+     * and key codes.
+     *
+     * <p>Handles Ctrl combinations, Tab/Shift+Tab, text input, Shift-based selection,
+     * and cursor navigation keys (Home, End, arrows, Page Up/Down). Alt key events
+     * are ignored.</p>
+     *
+     * @param event the keyboard event to process
+     */
+    private void processKeyEvent(final KeyEvent event) {
+        final int modifiers = event.getModifiersEx();
+        final int keyCode = event.getKeyCode();
+        final char keyChar = event.getKeyChar();
+
+        // System.out.println("Keycode:" + keyCode s+ ", keychar:" + keyChar);
+
+        if (KeyboardHelper.isAltPressed(modifiers))
+            return;
+
+        if (KeyboardHelper.isCtrlPressed(modifiers)) {
+            processCtrlCombinations(keyCode);
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.TAB) {
+            processTab(modifiers);
+            return;
+        }
+
+        clearSelection();
+
+        if (KeyboardHelper.isText(keyCode)) {
+            insertText(String.valueOf(keyChar));
+            return;
+        }
+
+        if (KeyboardHelper.isShiftPressed(modifiers)) {
+            if (!selecting)
+                attemptSelectionStart:{
+
+                    if (keyChar == 65535)
+                        if (keyCode == 16)
+                            break attemptSelectionStart;
+                    if (((keyChar >= 32) & (keyChar <= 128)) | (keyChar == 10)
+                            | (keyChar == 8) | (keyChar == 9))
+                        break attemptSelectionStart;
+
+                    selectionStart = new TextPointer(cursorLocation);
+                    selectionEnd = selectionStart;
+                    selecting = true;
+                    repaintPage();
+                }
+        } else
+            selecting = false;
+
+        if (keyCode == KeyboardHelper.HOME) {
+            cursorLocation.column = 0;
+            return;
+        }
+        if (keyCode == KeyboardHelper.END) {
+            cursorLocation.column = page.getLineLength(cursorLocation.row);
+            return;
+        }
+
+        // process cursor keys
+        if (keyCode == KeyboardHelper.DOWN) {
+            markRowDirty();
+            cursorLocation.row++;
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.UP) {
+            markRowDirty();
+            cursorLocation.row--;
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.RIGHT) {
+            cursorLocation.column++;
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.LEFT) {
+            cursorLocation.column--;
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.PGDOWN) {
+            cursorLocation.row += textCanvas.getSize().row;
+            repaintPage();
+            return;
+        }
+
+        if (keyCode == KeyboardHelper.PGUP) {
+            cursorLocation.row -= textCanvas.getSize().row;
+            repaintPage = true;
+        }
+
+    }
+
+    /**
+     * Processes the Tab key action for indentation and dedentation.
+     *
+     * <p>Behavior depends on modifiers and selection state:</p>
+     * <ul>
+     *   <li><strong>Shift+Tab with selection:</strong> dedents all selected lines by
+     *       removing up to 4 leading spaces, if all lines have sufficient indentation</li>
+     *   <li><strong>Shift+Tab without selection:</strong> dedents the current line by
+     *       removing 4 leading spaces and moving the cursor back</li>
+     *   <li><strong>Tab with selection:</strong> indents all selected lines by adding
+     *       4 leading spaces</li>
+     * </ul>
+     *
+     * @param modifiers the keyboard modifier flags from the key event
+     */
+    private void processTab(final int modifiers) {
+        if (KeyboardHelper.isShiftPressed(modifiers)) {
+            if (selectionStart.compareTo(selectionEnd) != 0) {
+                // dedent multiple lines
+                ensureSelectionOrder();
+
+                identSelection:
+                {
+                    // check that indentation is possible
+                    for (int y = selectionStart.row; y < selectionEnd.row; y++) {
+                        final TextLine textLine = page.getLine(y);
+
+                        if (!textLine.isEmpty())
+                            if (textLine.getIndent() < 4)
+                                break identSelection;
+                    }
+
+                    for (int y = selectionStart.row; y < selectionEnd.row; y++)
+                        page.getLine(y).cutFromBeginning(4);
+                }
+            } else {
+                // dedent current line
+                final TextLine textLine = page.getLine(cursorLocation.row);
+
+                if (cursorLocation.column >= 4)
+                    if (textLine.isEmpty())
+                        cursorLocation.column -= 4;
+                    else if (textLine.getIndent() >= 4) {
+                        cursorLocation.column -= 4;
+                        textLine.cutFromBeginning(4);
+                    }
+
+            }
+
+            repaintPage();
+
+        } else if (selectionStart.compareTo(selectionEnd) != 0) {
+            // indent multiple lines
+            ensureSelectionOrder();
+            for (int y = selectionStart.row; y < selectionEnd.row; y++)
+                page.getLine(y).addIndent(4);
+
+            repaintPage();
+        }
+    }
+
+    /**
+     * Repaints the entire visible page area onto the text canvas.
+     *
+     * <p>Iterates over every visible cell (row and column), applying the appropriate
+     * foreground and background colors based on whether the cell is the cursor position,
+     * part of a selection, or a tab stop margin. Characters are read from the underlying
+     * {@link Page} model with scroll offsets applied.</p>
+     */
+    public void repaintPage() {
+
+        final int columnCount = textCanvas.getSize().column + 2;
+        final int rowCount = textCanvas.getSize().row + 2;
+
+        for (int row = 0; row < rowCount; row++)
+            for (int column = 0; column < columnCount; column++) {
+                final boolean isTabMargin = ((column + scrolledCharacters) % 4) == 0;
+
+                if ((column == (cursorLocation.column - scrolledCharacters))
+                        & (row == (cursorLocation.row - scrolledLines))) {
+                    // cursor
+                    textCanvas.setBackgroundColor(lookAndFeel.cursorBackground);
+                    textCanvas.setForegroundColor(lookAndFeel.cursorForeground);
+                } else if (new TextPointer(row + scrolledLines, column).isBetween(
+                        selectionStart, selectionEnd)) {
+                    // selected text
+                    textCanvas.setBackgroundColor(lookAndFeel.selectionBackground);
+                    textCanvas.setForegroundColor(lookAndFeel.selectionForeground);
+                } else {
+                    // normal text
+                    textCanvas.setBackgroundColor(lookAndFeel.background);
+                    textCanvas.setForegroundColor(lookAndFeel.foreground);
+
+                    if (isTabMargin)
+                        textCanvas
+                                .setBackgroundColor(lookAndFeel.tabStopBackground);
+
+                }
+
+                final char charUnderCursor = page.getChar(row + scrolledLines,
+                        column + scrolledCharacters);
+
+                textCanvas.putChar(row, column, charUnderCursor);
+            }
+
+    }
+
+    /**
+     * Repaints a single row of the editor.
+     *
+     * <p><strong>Note:</strong> the current implementation delegates to
+     * {@link #repaintPage()} and repaints the entire page. This is a candidate
+     * for optimization.</p>
+     *
+     * @param rowNumber the zero-based row index to repaint
+     */
+    public void repaintRow(final int rowNumber) {
+        // TODO: Optimize this. No need to repaint entire page.
+        repaintPage();
+    }
+
+    /**
+     * Repaints only the portions of the editor that have been marked as dirty.
+     *
+     * <p>If {@link #repaintPage} is set, the entire page is repainted and all
+     * dirty row tracking is cleared. Otherwise, only the individually dirty rows
+     * are repainted.</p>
+     */
+    private void repaintWhatNeeded() {
+        if (repaintPage) {
+            dirtyRows.clear();
+            repaintPage();
+            return;
+        }
+
+        dirtyRows.forEach(this::repaintRow);
+        dirtyRows.clear();
+    }
+
+    /**
+     * Scrolls the visible editor area by the specified number of characters and lines.
+     *
+     * <p>Scroll offsets are clamped so they never go below zero. A full page
+     * repaint is scheduled after scrolling.</p>
+     *
+     * @param charactersToScroll the number of characters to scroll horizontally
+     *                           (positive = right, negative = left)
+     * @param linesToScroll      the number of lines to scroll vertically
+     *                           (positive = down, negative = up)
+     */
+    public void scroll(final int charactersToScroll, final int linesToScroll) {
+        scrolledLines += linesToScroll;
+        scrolledCharacters += charactersToScroll;
+
+        if (scrolledLines < 0)
+            scrolledLines = 0;
+
+        if (scrolledCharacters < 0)
+            scrolledCharacters = 0;
+
+        repaintPage = true;
+    }
+
+    /**
+     * Replaces the entire editor content with the given text.
+     *
+     * <p>Resets the cursor to position (0, 0), clears all scroll offsets and
+     * selections, creates a fresh {@link Page}, inserts the text, and triggers
+     * a full repaint.</p>
+     *
+     * @param text the new text content for the editor; may contain newline
+     *             characters to create multiple lines
+     */
+    public void setText(final String text) {
+        // System.out.println("Set text:" + text);
+        cursorLocation = new TextPointer(0, 0);
+        scrolledCharacters = 0;
+        scrolledLines = 0;
+        selectionStart = new TextPointer(0, 0);
+        selectionEnd = new TextPointer(0, 0);
+        page = new Page();
+        insertText(text);
+        repaintPage();
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLine.java
new file mode 100755 (executable)
index 0000000..671fa0f
--- /dev/null
@@ -0,0 +1,410 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a single line of text in the text editor.
+ *
+ * <p>Internally stores a mutable list of {@link Character} objects, one per character in
+ * the line. Provides operations for inserting, cutting, and copying substrings, as well
+ * as indentation manipulation (adding or removing leading spaces).</p>
+ *
+ * <p>Lines automatically trim trailing whitespace via the internal {@code pack()} method,
+ * which is invoked after most mutating operations. This ensures that lines never store
+ * unnecessary trailing space characters.</p>
+ *
+ * @see Character the wrapper for individual character values in a line
+ * @see Page the container that holds multiple {@code TextLine} instances
+ * @see TextEditComponent the text editor component that uses lines for editing
+ */
+public class TextLine {
+
+    private List<Character> chars = new ArrayList<>();
+
+    /**
+     * Creates an empty text line with no characters.
+     */
+    public TextLine() {
+    }
+
+    /**
+     * Creates a text line from an existing list of {@link Character} objects.
+     *
+     * <p>Trailing whitespace is automatically trimmed via {@code pack()}.</p>
+     *
+     * @param value the list of characters to initialize this line with
+     */
+    public TextLine(final List<Character> value) {
+        chars = value;
+        pack();
+    }
+
+    /**
+     * Creates a text line initialized with the given string.
+     *
+     * <p>Each character in the string is converted to a {@link Character} object.
+     * Trailing whitespace is automatically trimmed.</p>
+     *
+     * @param value the string to initialize this line with
+     */
+    public TextLine(final String value) {
+        setValue(value);
+    }
+
+    /**
+     * Adds indentation (leading spaces) to the beginning of this line.
+     *
+     * <p>If the line is empty, no indentation is added. Otherwise, the specified
+     * number of space characters are prepended to the line.</p>
+     *
+     * @param amount the number of space characters to prepend
+     */
+    public void addIndent(final int amount) {
+        if (isEmpty())
+            return;
+
+        for (int i = 0; i < amount; i++)
+            chars.add(0, new Character(' '));
+    }
+
+    /**
+     * Removes characters from the specified range and returns them as a string.
+     *
+     * <p>This is a destructive operation: the characters in the range
+     * [{@code from}, {@code until}) are removed from this line. If the line is
+     * shorter than {@code until}, it is padded with spaces before extraction.
+     * Trailing whitespace is trimmed after removal.</p>
+     *
+     * @param from  the start index (inclusive) of the range to extract
+     * @param until the end index (exclusive) of the range to extract
+     * @return the extracted characters as a string
+     */
+    public String copySubString(final int from, final int until) {
+        final StringBuilder result = new StringBuilder();
+
+        ensureLength(until);
+
+        for (int i = from; i < until; i++)
+            result.append(chars.remove(from).value);
+
+        pack();
+        return result.toString();
+    }
+
+
+    /**
+     * Removes the specified number of characters from the beginning of this line.
+     *
+     * <p>If {@code charactersToCut} exceeds the line length, the entire line is cleared.
+     * If {@code charactersToCut} is zero, no changes are made.</p>
+     *
+     * @param charactersToCut the number of leading characters to remove
+     */
+    public void cutFromBeginning(int charactersToCut) {
+
+        if (charactersToCut > chars.size())
+            charactersToCut = chars.size();
+
+        if (charactersToCut == 0)
+            return;
+
+        chars = chars.subList(charactersToCut, chars.size());
+    }
+
+    /**
+     * Extracts a substring from this line, removing those characters and returning them.
+     *
+     * <p>Characters in the range [{@code from}, {@code until}) are removed from this
+     * line and returned as a string. Characters outside the range are retained. If the
+     * line is shorter than {@code until}, it is padded with spaces before extraction.
+     * Trailing whitespace is trimmed after the cut.</p>
+     *
+     * @param from  the start index (inclusive) of the range to cut
+     * @param until the end index (exclusive) of the range to cut
+     * @return the cut characters as a string
+     */
+    public String cutSubString(final int from, final int until) {
+        final StringBuilder result = new StringBuilder();
+
+        final List<Character> reminder = new ArrayList<>();
+
+        ensureLength(until);
+
+        for (int i = 0; i < chars.size(); i++)
+            if ((i >= from) && (i < until))
+                result.append(chars.get(i).value);
+            else
+                reminder.add(chars.get(i));
+
+        chars = reminder;
+
+        pack();
+        return result.toString();
+    }
+
+    /**
+     * Truncates this line at the specified column, discarding all characters from
+     * that position to the end.
+     *
+     * <p>If {@code col} is greater than or equal to the current line length,
+     * no changes are made.</p>
+     *
+     * @param col the column index at which to truncate (exclusive; characters at
+     *            indices 0 through {@code col - 1} are kept)
+     */
+    public void cutUntilEnd(final int col) {
+        if (col >= chars.size())
+            return;
+
+        chars = chars.subList(0, col);
+    }
+
+    /**
+     * Ensures the internal character list is at least the given length,
+     * padding with space characters as needed.
+     */
+    private void ensureLength(final int length) {
+        while (chars.size() < length)
+            chars.add(new Character(' '));
+    }
+
+    /**
+     * Returns the character at the specified column position.
+     *
+     * <p>If the column is beyond the end of this line, a space character is returned.</p>
+     *
+     * @param col the zero-based column index
+     * @return the character at the given column, or {@code ' '} if out of bounds
+     */
+    public char getCharForLocation(final int col) {
+
+        if (col >= chars.size())
+            return ' ';
+
+        return chars.get(col).value;
+    }
+
+    /**
+     * Returns the internal list of {@link Character} objects backing this line.
+     *
+     * <p><strong>Note:</strong> the returned list is the live internal list. Modifications
+     * to the returned list will directly affect this line.</p>
+     *
+     * @return the mutable list of characters in this line
+     */
+    public List<Character> getChars() {
+        return chars;
+    }
+
+    /**
+     * Returns the indentation level of this line, measured as the number of
+     * leading space characters before the first non-space character.
+     *
+     * <p>If the line is empty, returns {@code 0}.</p>
+     *
+     * @return the number of leading space characters
+     * @throws RuntimeException if the line is non-empty but contains only spaces
+     *         (should not occur due to trailing whitespace trimming by {@code pack()})
+     */
+    public int getIndent() {
+        if (isEmpty())
+            return 0;
+
+        for (int i = 0; i < chars.size(); i++)
+            if (chars.get(i).hasValue())
+                return i;
+
+        throw new RuntimeException("This code shall never execute");
+    }
+
+    /**
+     * Returns the length of this line (number of characters, excluding trimmed
+     * trailing whitespace).
+     *
+     * @return the number of characters in this line
+     */
+    public int getLength() {
+        return chars.size();
+    }
+
+    /**
+     * Returns a new {@code TextLine} containing the characters from this line
+     * in the range [{@code from}, {@code until}).
+     *
+     * <p>If {@code until} exceeds the line length, only the available characters
+     * are included. The returned line is an independent copy.</p>
+     *
+     * @param from  the start index (inclusive)
+     * @param until the end index (exclusive)
+     * @return a new {@code TextLine} with the specified sub-range of characters
+     */
+    public TextLine getSubLine(final int from, final int until) {
+        final List<Character> result = new ArrayList<>();
+
+        for (int i = from; i < until; i++) {
+            if (i >= chars.size())
+                break;
+            result.add(chars.get(i));
+        }
+
+        return new TextLine(result);
+    }
+
+    /**
+     * Returns a substring of this line from column {@code from} (inclusive) to
+     * column {@code until} (exclusive).
+     *
+     * <p>If the requested range extends beyond the line length, space characters
+     * are used for positions past the end of the line.</p>
+     *
+     * @param from  the start column (inclusive)
+     * @param until the end column (exclusive)
+     * @return the substring in the specified range
+     */
+    public String getSubString(final int from, final int until) {
+        final StringBuilder result = new StringBuilder();
+
+        for (int i = from; i < until; i++)
+            result.append(getCharForLocation(i));
+
+        return result.toString();
+    }
+
+    /**
+     * Inserts a single character at the specified column position.
+     *
+     * <p>If the column is beyond the current line length, the line is padded
+     * with spaces up to that position. Trailing whitespace is trimmed after
+     * insertion.</p>
+     *
+     * @param col   the zero-based column at which to insert
+     * @param value the character to insert
+     */
+    public void insertCharacter(final int col, final char value) {
+        ensureLength(col);
+        chars.add(col, new Character(value));
+        pack();
+    }
+
+    /**
+     * Inserts a string at the specified column position.
+     *
+     * <p>Each character in the string is inserted sequentially starting at
+     * {@code col}. If the column is beyond the current line length, the line
+     * is padded with spaces. Trailing whitespace is trimmed after insertion.</p>
+     *
+     * @param col   the zero-based column at which to start inserting
+     * @param value the string to insert
+     */
+    public void insertString(final int col, final String value) {
+        ensureLength(col);
+        int i = 0;
+        for (final char c : value.toCharArray()) {
+            chars.add(col + i, new Character(c));
+            i++;
+        }
+        pack();
+    }
+
+    /**
+     * Inserts all characters from another {@code TextLine} at the specified column.
+     *
+     * <p>If the column is beyond the current line length, the line is padded with
+     * spaces. Trailing whitespace is trimmed after insertion.</p>
+     *
+     * @param col      the zero-based column at which to start inserting
+     * @param textLine the text line whose characters will be inserted
+     */
+    public void insertTextLine(final int col, final TextLine textLine) {
+        ensureLength(col);
+        int i = 0;
+        for (final Character c : textLine.getChars()) {
+            chars.add(col + i, c);
+            i++;
+        }
+        pack();
+    }
+
+    /**
+     * Returns whether this line contains no characters.
+     *
+     * <p>Because trailing whitespace is trimmed, an empty line means there are
+     * no visible characters on this line.</p>
+     *
+     * @return {@code true} if the line has no characters, {@code false} otherwise
+     */
+    public boolean isEmpty() {
+        return chars.isEmpty();
+    }
+
+    /**
+     * Trims trailing whitespace from this line by removing trailing space
+     * characters that have no visible content.
+     */
+    private void pack() {
+        int newLength = 0;
+
+        for (int i = chars.size() - 1; i >= 0; i--)
+            if (chars.get(i).hasValue()) {
+                newLength = i + 1;
+                break;
+            }
+
+        if (newLength == chars.size())
+            return;
+
+        chars = chars.subList(0, newLength);
+    }
+
+    /**
+     * Removes the character at the specified column position.
+     *
+     * <p>If the column is beyond the end of the line, no changes are made.</p>
+     *
+     * @param col the zero-based column of the character to remove
+     */
+    public void removeCharacter(final int col) {
+        if (col >= chars.size())
+            return;
+
+        chars.remove(col);
+    }
+
+    /**
+     * Replaces the entire contents of this line with the given string.
+     *
+     * <p>The existing characters are cleared, and each character from the string
+     * is added as a new {@link Character} object. Trailing whitespace is trimmed.</p>
+     *
+     * @param string the new text content for this line
+     */
+    public void setValue(final String string) {
+        chars.clear();
+        for (final char c : string.toCharArray())
+            chars.add(new Character(c));
+
+        pack();
+    }
+
+    /**
+     * Returns the string representation of this line by concatenating
+     * all character values.
+     *
+     * @return the text content of this line as a {@code String}
+     */
+    @Override
+    public String toString() {
+        final StringBuilder buffer = new StringBuilder();
+
+        for (final Character character : chars)
+            buffer.append(character.value);
+
+        return buffer.toString();
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java
new file mode 100644 (file)
index 0000000..53ea2a1
--- /dev/null
@@ -0,0 +1,6 @@
+/**
+ * Provides a simple text editor component rendered in 3D space.
+ *
+ * @see eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextEditComponent
+ */
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/GoldenImage.java
new file mode 100644 (file)
index 0000000..8bbd7e5
--- /dev/null
@@ -0,0 +1,168 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.headless;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.Locale;
+
+/**
+ * Golden-image comparison: render a scene, compare against a committed
+ * reference PNG, fail when the picture drifts.
+ *
+ * <p>A pixel counts as different when any RGB channel differs by more than
+ * a per-channel tolerance; a comparison fails when the fraction of
+ * differing pixels exceeds a threshold. Both are parameters — shading and
+ * antialiasing make exact matches fragile, but "the floor lost 30% of its
+ * pixels" must not pass.</p>
+ *
+ * <p><b>Example:</b></p>
+ * <pre>{@code
+ * BufferedImage actual = Snapshot.render(scene, lighting, pose, 640, 480);
+ * GoldenImage.Result r = GoldenImage.compare(actual, new File("goldens/house.png"), 8, 0.01);
+ * if (!r.passed) {
+ *     GoldenImage.saveDiff(actual, new File("goldens/house.png"), "/tmp/diff.png");
+ *     throw new AssertionError(r.toString());
+ * }
+ * }</pre>
+ *
+ * <p>Command line (for scripts):</p>
+ * <pre>
+ * java eu.svjatoslav.aukio.e3d.headless.GoldenImage actual.png golden.png [tolerance] [maxDiffFraction]
+ * exit code 0 = match, 1 = differ, 2 = usage/io error
+ * </pre>
+ *
+ * @see Snapshot
+ */
+public final class GoldenImage {
+
+    private GoldenImage() {
+        // utility class
+    }
+
+    /** Outcome of one comparison. */
+    public static final class Result {
+        /** True when the images match within tolerance. */
+        public final boolean passed;
+        /** Fraction of pixels that differ (0..1). */
+        public final double diffFraction;
+        /** Absolute count of differing pixels. */
+        public final int diffPixels;
+        /** Total pixels compared. */
+        public final int totalPixels;
+
+        Result(final boolean passed, final double diffFraction,
+               final int diffPixels, final int totalPixels) {
+            this.passed = passed;
+            this.diffFraction = diffFraction;
+            this.diffPixels = diffPixels;
+            this.totalPixels = totalPixels;
+        }
+
+        @Override
+        public String toString() {
+            return String.format(Locale.ROOT, "%s: %d/%d pixels differ (%.4f)",
+                    passed ? "PASS" : "FAIL", diffPixels, totalPixels, diffFraction);
+        }
+    }
+
+    /**
+     * Compares an image against a golden PNG file.
+     *
+     * @param actual          the rendered image
+     * @param goldenFile      the reference PNG
+     * @param channelTolerance per-channel (R/G/B) tolerance, 0 = exact
+     * @param maxDiffFraction maximum allowed fraction of differing pixels (0..1)
+     * @return the comparison result
+     * @throws IOException on read failure or size mismatch
+     */
+    public static Result compare(final BufferedImage actual, final File goldenFile,
+                                 final int channelTolerance, final double maxDiffFraction)
+            throws IOException {
+        final BufferedImage golden = ImageIO.read(goldenFile);
+        if (golden == null)
+            throw new IOException("cannot read golden image: " + goldenFile);
+        if (golden.getWidth() != actual.getWidth() || golden.getHeight() != actual.getHeight())
+            throw new IOException("size mismatch: actual " + actual.getWidth() + "x" + actual.getHeight()
+                    + " vs golden " + golden.getWidth() + "x" + golden.getHeight());
+
+        int diff = 0;
+        final int width = actual.getWidth();
+        final int height = actual.getHeight();
+        for (int y = 0; y < height; y++) {
+            for (int x = 0; x < width; x++) {
+                if (differs(actual.getRGB(x, y), golden.getRGB(x, y), channelTolerance))
+                    diff++;
+            }
+        }
+        final int total = width * height;
+        final double fraction = (double) diff / total;
+        return new Result(fraction <= maxDiffFraction, fraction, diff, total);
+    }
+
+    /**
+     * Writes a visual diff image: matching pixels dimmed, differing pixels
+     * highlighted red. Handy for inspecting a failed comparison.
+     *
+     * @param actual     the rendered image
+     * @param goldenFile the reference PNG
+     * @param outPath    where to write the diff PNG
+     * @throws IOException on read/write failure or size mismatch
+     */
+    public static void saveDiff(final BufferedImage actual, final File goldenFile,
+                                final String outPath) throws IOException {
+        final BufferedImage golden = ImageIO.read(goldenFile);
+        final BufferedImage diff = new BufferedImage(actual.getWidth(), actual.getHeight(),
+                BufferedImage.TYPE_INT_RGB);
+        for (int y = 0; y < actual.getHeight(); y++) {
+            for (int x = 0; x < actual.getWidth(); x++) {
+                final int a = actual.getRGB(x, y);
+                if (differs(a, golden.getRGB(x, y), 0)) {
+                    diff.setRGB(x, y, 0xFF0000);
+                } else {
+                    // dimmed original: quarter brightness
+                    diff.setRGB(x, y, ((a >> 2) & 0x3F3F3F));
+                }
+            }
+        }
+        ImageIO.write(diff, "png", new File(outPath));
+    }
+
+    /** True when any channel of the two RGB values differs by more than tolerance. */
+    private static boolean differs(final int rgb1, final int rgb2, final int tolerance) {
+        for (int shift = 16; shift >= 0; shift -= 8) {
+            final int c1 = (rgb1 >> shift) & 0xFF;
+            final int c2 = (rgb2 >> shift) & 0xFF;
+            if (Math.abs(c1 - c2) > tolerance)
+                return true;
+        }
+        return false;
+    }
+
+    /**
+     * CLI: compares two PNGs. Exit 0 = match, 1 = differ, 2 = error.
+     *
+     * @param args actual.png golden.png [channelTolerance] [maxDiffFraction]
+     */
+    public static void main(final String[] args) {
+        if (args.length < 2) {
+            System.err.println("usage: GoldenImage actual.png golden.png [channelTolerance] [maxDiffFraction]");
+            System.exit(2);
+        }
+        try {
+            final int tolerance = args.length > 2 ? Integer.parseInt(args[2]) : 0;
+            final double maxFraction = args.length > 3 ? Double.parseDouble(args[3]) : 0.0;
+            final BufferedImage actual = ImageIO.read(new File(args[0]));
+            final Result result = compare(actual, new File(args[1]), tolerance, maxFraction);
+            System.out.println(result);
+            System.exit(result.passed ? 0 : 1);
+        } catch (final Exception e) {
+            System.err.println("error: " + e.getMessage());
+            System.exit(2);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/PixelAssertions.java
new file mode 100644 (file)
index 0000000..21286d1
--- /dev/null
@@ -0,0 +1,146 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.headless;
+
+import java.awt.image.BufferedImage;
+
+/**
+ * Pixel-level assertions for headless render verification.
+ *
+ * <p>The recurring question in rasterizer debugging is "did this region of
+ * the screen actually get painted?" These helpers answer it against a
+ * {@link BufferedImage} produced by {@link Snapshot}, treating one chosen
+ * background color as "unpainted". All methods are static and return data
+ * rather than throwing, so callers decide how to report failures.</p>
+ *
+ * <p><b>Example — assert the floor rendered (no holes from clipping):</b></p>
+ * <pre>{@code
+ * BufferedImage image = Snapshot.render(scene, lighting, pose, 640, 480);
+ * double holes = PixelAssertions.unpaintedFraction(image, 0x00000000,
+ *         0.15, 0.45, 0.85, 1.0);  // lower-center band
+ * if (holes > 0.05)
+ *     throw new AssertionError("floor has holes: " + holes);
+ * }</pre>
+ *
+ * @see Snapshot
+ * @see GoldenImage
+ */
+public final class PixelAssertions {
+
+    private PixelAssertions() {
+        // utility class
+    }
+
+    /**
+     * Fraction of pixels in the whole image that still equal the background
+     * color (i.e. nothing was painted there).
+     *
+     * @param image          the rendered image
+     * @param backgroundRgb  the background color (RGB, alpha ignored)
+     * @return fraction in [0, 1]
+     */
+    public static double unpaintedFraction(final BufferedImage image, final int backgroundRgb) {
+        return unpaintedFraction(image, backgroundRgb, 0, 0, 1, 1);
+    }
+
+    /**
+     * Fraction of pixels inside a relative rectangle that still equal the
+     * background color.
+     *
+     * @param image         the rendered image
+     * @param backgroundRgb the background color (RGB, alpha ignored)
+     * @param x0            left edge, fraction of width (0..1)
+     * @param y0            top edge, fraction of height (0..1)
+     * @param x1            right edge, fraction of width (0..1)
+     * @param y1            bottom edge, fraction of height (0..1)
+     * @return fraction in [0, 1]
+     */
+    public static double unpaintedFraction(final BufferedImage image, final int backgroundRgb,
+                                           final double x0, final double y0,
+                                           final double x1, final double y1) {
+        final int width = image.getWidth();
+        final int height = image.getHeight();
+        final int bg = backgroundRgb & 0xFFFFFF;
+
+        int total = 0;
+        int unpainted = 0;
+        for (int y = (int) (height * y0); y < (int) (height * y1); y++) {
+            for (int x = (int) (width * x0); x < (int) (width * x1); x++) {
+                total++;
+                if ((image.getRGB(x, y) & 0xFFFFFF) == bg)
+                    unpainted++;
+            }
+        }
+        return total == 0 ? 0 : (double) unpainted / total;
+    }
+
+    /**
+     * Counts pixels in the whole image that differ from the background color.
+     *
+     * @param image         the rendered image
+     * @param backgroundRgb the background color (RGB, alpha ignored)
+     * @return number of painted pixels
+     */
+    public static long countPainted(final BufferedImage image, final int backgroundRgb) {
+        final int bg = backgroundRgb & 0xFFFFFF;
+        long painted = 0;
+        for (int y = 0; y < image.getHeight(); y++)
+            for (int x = 0; x < image.getWidth(); x++)
+                if ((image.getRGB(x, y) & 0xFFFFFF) != bg)
+                    painted++;
+        return painted;
+    }
+
+    /**
+     * Counts pixels equal (in RGB) to the given color. Useful with flat
+     * test colors: "how much of the red triangle made it to the screen?"
+     *
+     * @param image   the rendered image
+     * @param rgb     the color to count (alpha ignored)
+     * @return number of matching pixels
+     */
+    public static long countColor(final BufferedImage image, final int rgb) {
+        final int wanted = rgb & 0xFFFFFF;
+        long count = 0;
+        for (int y = 0; y < image.getHeight(); y++)
+            for (int x = 0; x < image.getWidth(); x++)
+                if ((image.getRGB(x, y) & 0xFFFFFF) == wanted)
+                    count++;
+        return count;
+    }
+
+    /**
+     * Dumps a grid of pixel colors around a point as text, for eyeballing
+     * gradients/edges in a failing render. Samples every {@code stride}
+     * pixels, formatted as RRGGBB hex, one row per line.
+     *
+     * @param image  the rendered image
+     * @param cx     center X in pixels
+     * @param cy     center Y in pixels
+     * @param radius grid extends this many samples in each direction
+     * @param stride pixels between samples
+     * @return multi-line hex grid string
+     */
+    public static String dumpPixelGrid(final BufferedImage image,
+                                       final int cx, final int cy,
+                                       final int radius, final int stride) {
+        final StringBuilder sb = new StringBuilder();
+        for (int dy = -radius; dy <= radius; dy++) {
+            for (int dx = -radius; dx <= radius; dx++) {
+                final int x = cx + dx * stride;
+                final int y = cy + dy * stride;
+                if (x < 0 || y < 0 || x >= image.getWidth() || y >= image.getHeight()) {
+                    sb.append("  ---- ");
+                } else {
+                    sb.append(String.format("%06X", image.getRGB(x, y) & 0xFFFFFF));
+                }
+                if (dx < radius)
+                    sb.append(' ');
+            }
+            sb.append('\n');
+        }
+        return sb.toString();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/SceneDump.java
new file mode 100644 (file)
index 0000000..3d32901
--- /dev/null
@@ -0,0 +1,103 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.headless;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+
+import java.util.Locale;
+
+/**
+ * Text dump of everything that determines what a frame looks like: shape
+ * counts, lights, camera pose, GI status. One call, one string — paste it
+ * into a bug report and the scene is reproducible.
+ *
+ * <p><b>Example:</b></p>
+ * <pre>{@code
+ * System.out.println(SceneDump.dump(scene, lighting, camera, gi));
+ * }</pre>
+ *
+ * <p>Sample output:</p>
+ * <pre>
+ * == SceneDump ==
+ * shapes: 214 top-level, 1892 queued for rendering
+ * lights: 4 (ambient #181818)
+ *   [0] pos=(-800.0, -240.0, 0.0) color=FFD890 intensity=6.0
+ *   ...
+ * camera: 290.31, -35.59, -2.10, -0.58, -0.15, -0.00
+ * GI: running, 152034 work items, converged
+ * </pre>
+ *
+ * @see Snapshot#poseString(Camera)
+ */
+public final class SceneDump {
+
+    private SceneDump() {
+        // utility class
+    }
+
+    /**
+     * Dumps scene state to a human-readable string.
+     *
+     * @param scene    the scene (may be null to skip shape counts)
+     * @param lighting the lighting manager (may be null to skip lights)
+     * @param camera   the camera (may be null to skip the pose)
+     * @param gi       the GI system, or null when GI is not in use
+     * @return the dump, one fact per line
+     */
+    public static String dump(final ShapeCollection scene,
+                              final LightingManager lighting,
+                              final Camera camera,
+                              final GlobalIllumination gi) {
+        final StringBuilder sb = new StringBuilder("== SceneDump ==\n");
+
+        if (scene != null) {
+            int topLevel = 0;
+            for (final AbstractShape ignored : scene.getShapes())
+                topLevel++;
+            sb.append("shapes: ").append(topLevel)
+                    .append(" top-level, ").append(scene.getQueuedShapeCount())
+                    .append(" queued for rendering\n");
+        }
+
+        if (lighting != null) {
+            sb.append("lights: ").append(lighting.getLights().size())
+                    .append(" (ambient #")
+                    .append(String.format("%06X", rgbOf(lighting.getAmbientLight())))
+                    .append(")\n");
+            int i = 0;
+            for (final LightSource light : lighting.getLights()) {
+                sb.append(String.format(Locale.ROOT,
+                        "  [%d] pos=(%.1f, %.1f, %.1f) color=%06X intensity=%.1f%n",
+                        i++, light.getPosition().x, light.getPosition().y, light.getPosition().z,
+                        rgbOf(light.getColor()), light.getIntensity()));
+            }
+        }
+
+        if (camera != null)
+            sb.append("camera: ").append(Snapshot.poseString(camera)).append('\n');
+
+        if (gi != null) {
+            sb.append("GI: ");
+            if (!gi.isRunning()) {
+                sb.append("stopped\n");
+            } else {
+                sb.append("running, ").append(gi.getWorkItemCount()).append(" work items, ")
+                        .append(gi.isConverged() ? "converged" : "converging").append('\n');
+            }
+        }
+
+        return sb.toString();
+    }
+
+    /** Packs an engine Color's r/g/b fields into a 0xRRGGBB int. */
+    private static int rgbOf(final eu.svjatoslav.aukio.e3d.renderer.raster.Color color) {
+        return ((color.r & 0xFF) << 16) | ((color.g & 0xFF) << 8) | (color.b & 0xFF);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/Snapshot.java
new file mode 100644 (file)
index 0000000..010c15e
--- /dev/null
@@ -0,0 +1,200 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.headless;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+
+/**
+ * One-call facade for rendering a scene to an image without a window.
+ *
+ * <p>Assembles the same transform &rarr; sort &rarr; paint pipeline that
+ * {@code ViewPanel} drives on screen, but into an off-screen
+ * {@link RenderingContext} pixel buffer. No Swing frame, no X display, no
+ * render thread — safe to use from tests, doc tooling and batch jobs.</p>
+ *
+ * <p><b>Example:</b></p>
+ * <pre>{@code
+ * ShapeCollection scene = new ShapeCollection();
+ * scene.addShape(myShape);
+ *
+ * LightingManager lighting = new LightingManager();
+ * lighting.setAmbientLight(Color.hex("181818"));
+ *
+ * BufferedImage image = Snapshot.render(scene, lighting,
+ *         "290.31, -35.59, -2.10, -0.58, -0.15, 0.0", 640, 480);
+ * Snapshot.save(image, "/tmp/snapshot.png");
+ * }</pre>
+ *
+ * <p>The pose string is the same "x, y, z, yaw, pitch, roll" format the
+ * demos print for camera positions, so a pose copy-pasted from a bug
+ * report reproduces the exact view.</p>
+ *
+ * @see PixelAssertions for checking what got painted
+ * @see GoldenImage for comparing against a reference PNG
+ * @see SceneDump for inspecting the scene state behind an image
+ */
+public final class Snapshot {
+
+    private Snapshot() {
+        // utility class
+    }
+
+    /**
+     * Renders the scene once from the given pose string.
+     *
+     * @param scene    the scene to render
+     * @param lighting lighting for the frame (may be null for unlit scenes)
+     * @param pose     "x, y, z, yaw, pitch, roll" — same format demos print
+     * @param width    image width in pixels
+     * @param height   image height in pixels
+     * @return the rendered frame (background is black)
+     */
+    public static BufferedImage render(final ShapeCollection scene,
+                                       final LightingManager lighting,
+                                       final String pose,
+                                       final int width, final int height) {
+        return render(scene, lighting, cameraFromPose(pose), width, height);
+    }
+
+    /**
+     * Renders the scene once from the given camera.
+     *
+     * @param scene    the scene to render
+     * @param lighting lighting for the frame (may be null for unlit scenes)
+     * @param camera   positioned camera
+     * @param width    image width in pixels
+     * @param height   image height in pixels
+     * @return the rendered frame (background is black)
+     */
+    public static BufferedImage render(final ShapeCollection scene,
+                                       final LightingManager lighting,
+                                       final Camera camera,
+                                       final int width, final int height) {
+        final RenderingContext ctx = new RenderingContext(width, height, 1);
+        ctx.lightingManager = lighting;
+        renderInto(scene, camera, ctx, 0);
+        return ctx.getImage();
+    }
+
+    /**
+     * Renders one frame into an existing context, filling the background
+     * with the given ARGB color first. Use a unique sentinel color when the
+     * caller needs to distinguish "nothing painted here" from "painted
+     * black" (e.g. hole detection in clipping tests).
+     *
+     * @param scene          the scene to render
+     * @param camera         positioned camera
+     * @param ctx            target context (its {@code pixels} buffer is painted)
+     * @param backgroundArgb background fill applied before painting
+     */
+    public static void renderInto(final ShapeCollection scene,
+                                  final Camera camera,
+                                  final RenderingContext ctx,
+                                  final int backgroundArgb) {
+        final Point3D location = camera.getTransform().getTranslation();
+        ctx.viewerPosition.x = location.x;
+        ctx.viewerPosition.y = location.y;
+        ctx.viewerPosition.z = location.z;
+
+        Arrays.fill(ctx.pixels, backgroundArgb);
+        Arrays.fill(ctx.depth, Float.NEGATIVE_INFINITY);
+        scene.transformShapes(camera, ctx);
+        scene.sortShapes(ctx.vertexSlot);
+        scene.paintShapes(ctx);
+        if (System.getProperty("aukio.zbuffer.dumpDepth") != null)
+            dumpDepth(ctx, System.getProperty("aukio.zbuffer.dumpDepth"));
+        ctx.frameNumber++;
+    }
+
+    /**
+     * Debug helper: saves the depth buffer as a grayscale PNG (z = 1/w,
+     * log-scaled; untouched pixels black). Enabled per render via
+     * {@code -Daukio.zbuffer.dumpDepth=/path.png}.
+     */
+    private static void dumpDepth(final RenderingContext ctx,
+                                  final String path) {
+        final int w = ctx.width;
+        final int h = ctx.height;
+        final BufferedImage img = new BufferedImage(w, h,
+                BufferedImage.TYPE_BYTE_GRAY);
+        final byte[] out = ((java.awt.image.DataBufferByte)
+                img.getRaster().getDataBuffer()).getData();
+        double maxLog = 1;
+        for (int i = 0; i < w * h; i++) {
+            final float dw = ctx.depth[i];
+            if (dw > 0)
+                maxLog = Math.max(maxLog, Math.log(1d / dw));
+        }
+        for (int i = 0; i < w * h; i++) {
+            final float dw = ctx.depth[i];
+            if (dw > 0) {
+                final double z = 1d / dw;
+                out[i] = (byte) (255 * Math.log(z) / maxLog);
+            }
+        }
+        try {
+            javax.imageio.ImageIO.write(img, "png",
+                    new java.io.File(path));
+        } catch (final java.io.IOException e) {
+            System.out.println("depth dump failed: " + e.getMessage());
+        }
+    }
+
+    /**
+     * Builds a camera from a "x, y, z, yaw, pitch, roll" pose string, as
+     * printed by demos (see {@link #poseString(Camera)}).
+     *
+     * @param pose the pose string; commas and extra whitespace tolerated
+     * @return a camera at that pose
+     */
+    public static Camera cameraFromPose(final String pose) {
+        final String[] parts = pose.split(",");
+        if (parts.length != 6)
+            throw new IllegalArgumentException(
+                    "pose must have 6 comma-separated values (x, y, z, yaw, pitch, roll), got: " + pose);
+        final double[] v = new double[6];
+        for (int i = 0; i < 6; i++)
+            v[i] = Double.parseDouble(parts[i].trim());
+        final Camera camera = new Camera();
+        camera.getTransform().set(v[0], v[1], v[2], v[3], v[4], v[5]);
+        return camera;
+    }
+
+    /**
+     * Formats a camera's pose as "x, y, z, yaw, pitch, roll" — the exact
+     * string {@link #cameraFromPose(String)} parses back. Intended for bug
+     * reports: paste the pose, reproduce the view.
+     *
+     * @param camera the camera to describe
+     * @return the pose string
+     */
+    public static String poseString(final Camera camera) {
+        final Point3D p = camera.getTransform().getTranslation();
+        final double[] angles = camera.getTransform().getRotation().toAngles();
+        return String.format(java.util.Locale.ROOT, "%.2f, %.2f, %.2f, %.2f, %.2f, %.2f",
+                p.x, p.y, p.z, angles[0], angles[1], angles[2]);
+    }
+
+    /**
+     * Saves an image as PNG.
+     *
+     * @param image the image to save
+     * @param path  target file path
+     * @throws IOException on write failure
+     */
+    public static void save(final BufferedImage image, final String path) throws IOException {
+        ImageIO.write(image, "png", new File(path));
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/headless/package-info.java
new file mode 100644 (file)
index 0000000..59fae56
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Headless rendering toolkit: windowless snapshots, pixel assertions,
+ * golden-image comparison and scene-state dumps.
+ *
+ * <p>Everything here works without a display, a Swing frame or a render
+ * thread, driving the same transform/sort/paint pipeline the on-screen
+ * path uses. Built for automated verification (tests, doc tooling, AI
+ * agents), but equally useful for batch thumbnail generation.</p>
+ *
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.headless.Snapshot} — render a scene to a BufferedImage; pose strings</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.headless.PixelAssertions} — "did this region get painted?"</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.headless.GoldenImage} — compare against a reference PNG</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.headless.SceneDump} — shapes/lights/camera/GI state as text</li>
+ * </ul>
+ */
+package eu.svjatoslav.aukio.e3d.headless;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/DiamondSquare.java
new file mode 100644 (file)
index 0000000..500f96b
--- /dev/null
@@ -0,0 +1,171 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+package eu.svjatoslav.aukio.e3d.math;
+
+import java.util.Random;
+
+/**
+ * Diamond-square algorithm for procedural noise generation.
+ * <p>
+ * Generates realistic fractal noise suitable for terrain, textures,
+ * and other procedural content. The algorithm produces a 2D map
+ * where each value falls within the specified [min, max] range.
+ * <p>
+ * Grid size must be 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257).
+ *
+ * @see <a href="https://en.wikipedia.org/wiki/Diamond-square_algorithm">Diamond-square algorithm</a>
+ */
+public final class DiamondSquare {
+
+    private static final double DEFAULT_ROUGHNESS = 0.6;
+
+    private DiamondSquare() {
+    }
+
+    /**
+     * Generates a fractal noise map using the diamond-square algorithm.
+     *
+     * @param gridSize the size of the grid (must be 2^n + 1)
+     * @param min      the minimum value in the output
+     * @param max      the maximum value in the output
+     * @param seed     random seed for reproducible results
+     * @return a 2D array of values in range [min, max]
+     * @throws IllegalArgumentException if gridSize is not 2^n + 1
+     */
+    public static double[][] generateMap(int gridSize, double min, double max, long seed) {
+        return generateMap(gridSize, min, max, DEFAULT_ROUGHNESS, seed);
+    }
+
+    /**
+     * Generates a fractal noise map using the diamond-square algorithm with custom roughness.
+     *
+     * @param gridSize  the size of the grid (must be 2^n + 1)
+     * @param min       the minimum value in the output
+     * @param max       the maximum value in the output
+     * @param roughness the roughness factor (0.0 to 1.0), higher values produce more variation
+     * @param seed      random seed for reproducible results
+     * @return a 2D array of values in range [min, max]
+     * @throws IllegalArgumentException if gridSize is not 2^n + 1
+     */
+    public static double[][] generateMap(int gridSize, double min, double max, double roughness, long seed) {
+        if (!isValidGridSize(gridSize)) {
+            throw new IllegalArgumentException("Grid size must be 2^n + 1 (e.g., 65, 129, 257)");
+        }
+
+        Random random = new Random(seed);
+        double[][] map = new double[gridSize][gridSize];
+
+        map[0][0] = random.nextDouble();
+        map[0][gridSize - 1] = random.nextDouble();
+        map[gridSize - 1][0] = random.nextDouble();
+        map[gridSize - 1][gridSize - 1] = random.nextDouble();
+
+        int stepSize = gridSize - 1;
+        double currentScale = roughness;
+
+        while (stepSize > 1) {
+            int halfStep = stepSize / 2;
+
+            for (int y = 0; y < gridSize - 1; y += stepSize) {
+                for (int x = 0; x < gridSize - 1; x += stepSize) {
+                    double avg = (map[y][x] +
+                            map[y][x + stepSize] +
+                            map[y + stepSize][x] +
+                            map[y + stepSize][x + stepSize]) / 4.0;
+                    map[y + halfStep][x + halfStep] =
+                            avg + (random.nextDouble() - 0.5) * currentScale;
+                }
+            }
+
+            for (int y = 0; y < gridSize; y += stepSize) {
+                for (int x = 0; x < gridSize; x += stepSize) {
+                    if (x + halfStep < gridSize) {
+                        double avg = map[y][x];
+                        if (x - halfStep >= 0) {
+                            avg += map[y][x - halfStep];
+                        }
+                        if (x + stepSize < gridSize) {
+                            avg += map[y][x + stepSize];
+                        }
+                        if (y + halfStep < gridSize) {
+                            avg += map[y + halfStep][x + halfStep];
+                        } else if (y - halfStep >= 0) {
+                            avg += map[y - halfStep][x + halfStep];
+                        }
+                        map[y][x + halfStep] =
+                                avg / 4.0 + (random.nextDouble() - 0.5) * currentScale;
+                    }
+
+                    if (y + halfStep < gridSize) {
+                        double avg = map[y][x];
+                        if (y - halfStep >= 0) {
+                            avg += map[y - halfStep][x];
+                        }
+                        if (y + stepSize < gridSize) {
+                            avg += map[y + stepSize][x];
+                        }
+                        if (x + halfStep < gridSize) {
+                            avg += map[y + halfStep][x + halfStep];
+                        } else if (x - halfStep >= 0) {
+                            avg += map[y + halfStep][x - halfStep];
+                        }
+                        map[y + halfStep][x] =
+                                avg / 4.0 + (random.nextDouble() - 0.5) * currentScale;
+                    }
+                }
+            }
+
+            stepSize = halfStep;
+            currentScale *= roughness;
+        }
+
+        normalize(map, min, max);
+        return map;
+    }
+
+    private static void normalize(double[][] map, double min, double max) {
+        double actualMin = Double.MAX_VALUE;
+        double actualMax = Double.MIN_VALUE;
+
+        for (double[] row : map) {
+            for (double value : row) {
+                if (value < actualMin) actualMin = value;
+                if (value > actualMax) actualMax = value;
+            }
+        }
+
+        double range = actualMax - actualMin;
+        double targetRange = max - min;
+
+        if (range == 0) {
+            for (int y = 0; y < map.length; y++) {
+                for (int x = 0; x < map[y].length; x++) {
+                    map[y][x] = min;
+                }
+            }
+            return;
+        }
+
+        for (int y = 0; y < map.length; y++) {
+            for (int x = 0; x < map[y].length; x++) {
+                map[y][x] = min + (map[y][x] - actualMin) / range * targetRange;
+            }
+        }
+    }
+
+    /**
+     * Checks if the grid size is valid for the diamond-square algorithm.
+     * Valid sizes are 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257).
+     *
+     * @param size the grid size to validate
+     * @return true if the size is valid
+     */
+    public static boolean isValidGridSize(int size) {
+        if (size < 3) return false;
+        int value = size - 1;
+        return (value & (value - 1)) == 0;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Matrix3x3.java
new file mode 100644 (file)
index 0000000..b7ca82d
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+/**
+ * A 3x3 matrix for 3D transformations.
+ *
+ * <p>Matrix elements are stored in row-major order:</p>
+ * <pre>
+ * | m00 m01 m02 |
+ * | m10 m11 m12 |
+ * | m20 m21 m22 |
+ * </pre>
+ *
+ * @see Point3D
+ */
+public class Matrix3x3 {
+
+    public double m00;
+    public double m01;
+    public double m02;
+    public double m10;
+    public double m11;
+    public double m12;
+    public double m20;
+    public double m21;
+    public double m22;
+
+    /**
+     * Creates a zero matrix.
+     */
+    public Matrix3x3() {
+    }
+
+    /**
+     * Returns an identity matrix.
+     *
+     * @return a new identity matrix
+     */
+    public static Matrix3x3 identity() {
+        final Matrix3x3 m = new Matrix3x3();
+        m.m00 = 1;
+        m.m11 = 1;
+        m.m22 = 1;
+        return m;
+    }
+
+    /**
+     * Applies this matrix transformation to a point.
+     *
+     * @param in  the input point (not modified)
+     * @param out the output point (will be modified)
+     */
+    public void transform(final Point3D in, final Point3D out) {
+        final double x = m00 * in.x + m01 * in.y + m02 * in.z;
+        final double y = m10 * in.x + m11 * in.y + m12 * in.z;
+        final double z = m20 * in.x + m21 * in.y + m22 * in.z;
+        out.x = x;
+        out.y = y;
+        out.z = z;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Quaternion.java
new file mode 100644 (file)
index 0000000..c5d9fd3
--- /dev/null
@@ -0,0 +1,281 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+import static java.lang.Math.cos;
+import static java.lang.Math.sin;
+
+/**
+ * A unit quaternion representing a 3D rotation.
+ *
+ * <p>Quaternions provide a compact representation of rotations that avoids
+ * gimbal lock and enables smooth interpolation (slerp).</p>
+ *
+ * <p>Usage example:</p>
+ * <pre>{@code
+ * // Create a rotation from yaw and pitch angles
+ * Quaternion rotation = Quaternion.fromAngles(0.5, -0.3);
+ *
+ * // Apply rotation to a point
+ * Point3D point = new Point3D(1, 0, 0);
+ * rotation.rotate(point);
+ *
+ * // Combine rotations
+ * Quaternion combined = rotation.multiply(otherRotation);
+ * }</pre>
+ *
+ * @see Matrix3x3
+ * @see Transform
+ */
+public class Quaternion {
+
+    /**
+     * The scalar (real) component of the quaternion.
+     */
+    public double w;
+
+    /**
+     * The i component (x-axis rotation factor).
+     */
+    public double x;
+
+    /**
+     * The j component (y-axis rotation factor).
+     */
+    public double y;
+
+    /**
+     * The k component (z-axis rotation factor).
+     */
+    public double z;
+
+    /**
+     * Creates an identity quaternion representing no rotation.
+     * Equivalent to Quaternion(1, 0, 0, 0).
+     */
+    public Quaternion() {
+        this.w = 1;
+        this.x = 0;
+        this.y = 0;
+        this.z = 0;
+    }
+
+    /**
+     * Creates a quaternion with the specified components.
+     *
+     * @param w the scalar component
+     * @param x the i component
+     * @param y the j component
+     * @param z the k component
+     */
+    public Quaternion(final double w, final double x, final double y, final double z) {
+        this.w = w;
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+
+    /**
+     * Returns the identity quaternion representing no rotation.
+     *
+     * @return the identity quaternion (1, 0, 0, 0)
+     */
+    public static Quaternion identity() {
+        return new Quaternion(1, 0, 0, 0);
+    }
+
+    /**
+     * Creates a quaternion from an axis-angle representation.
+     *
+     * @param axis  the rotation axis (must be normalized)
+     * @param angle the rotation angle in radians
+     * @return a quaternion representing the rotation
+     */
+    public static Quaternion fromAxisAngle(final Point3D axis, final double angle) {
+        final double halfAngle = angle / 2;
+        final double s = sin(halfAngle);
+        final double c = cos(halfAngle);
+        return new Quaternion(c, axis.x * s, axis.y * s, axis.z * s);
+    }
+
+    /**
+     * Creates a quaternion from XZ (yaw) and YZ (pitch) Euler angles.
+     *
+     * <p>The rotation is composed as yaw (around Y axis) followed by
+     * pitch (around X axis). No roll rotation is applied.</p>
+     *
+     * <p>For full 3-axis rotation, use {@link #fromAngles(double, double, double)}.</p>
+     *
+     * @param angleXZ the angle around the XZ axis (yaw) in radians
+     * @param angleYZ the angle around the YZ axis (pitch) in radians
+     * @return a quaternion representing the combined rotation
+     */
+    public static Quaternion fromAngles(final double angleXZ, final double angleYZ) {
+        return fromAngles(angleXZ, angleYZ, 0);
+    }
+
+    /**
+     * Creates a quaternion from full Euler angles (yaw, pitch, roll).
+     *
+     * <p>Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard
+     * Y-X-Z Euler order commonly used for object placement in 3D scenes.</p>
+     *
+     * <p><b>Performance note:</b> This method uses a direct Euler-to-quaternion
+     * formula to avoid intermediate allocations.</p>
+     *
+     * @param yaw   rotation around Y axis (horizontal heading) in radians
+     * @param pitch rotation around X axis (vertical tilt) in radians;
+     *              positive values tilt upward
+     * @param roll  rotation around Z axis (bank/tilt) in radians;
+     *              positive values rotate clockwise when looking along +Z
+     * @return a quaternion representing the combined rotation
+     */
+    public static Quaternion fromAngles(final double yaw, final double pitch, final double roll) {
+        // Half angles for the Euler-to-quaternion conversion
+        final double cy = cos(yaw * 0.5);
+        final double sy = sin(yaw * 0.5);
+        final double cp = cos(pitch * 0.5);
+        final double sp = sin(pitch * 0.5);
+        final double cr = cos(roll * 0.5);
+        final double sr = sin(roll * 0.5);
+
+        // Direct formula for Y-X-Z Euler order with negated pitch
+        // Equivalent to: qRoll * qPitch(−pitch) * qYaw
+        return new Quaternion(
+                cr * cp * cy + sr * sp * sy,   // w
+                -cr * sp * cy - sr * cp * sy,  // x
+                cr * cp * sy - sr * sp * cy,   // y
+                -cr * sp * sy + sr * cp * cy   // z
+        );
+    }
+
+    /**
+     * Creates a copy of this quaternion.
+     *
+     * @return a new quaternion with the same component values
+     */
+    public Quaternion clone() {
+        return new Quaternion(w, x, y, z);
+    }
+
+    /**
+     * Copies the values from another quaternion into this one.
+     *
+     * @param other the quaternion to copy from
+     */
+    public void set(final Quaternion other) {
+        this.w = other.w;
+        this.x = other.x;
+        this.y = other.y;
+        this.z = other.z;
+    }
+
+    /**
+     * Multiplies this quaternion by another (Hamilton product).
+     *
+     * @param other the quaternion to multiply by
+     * @return a new quaternion representing the combined rotation
+     */
+    public Quaternion multiply(final Quaternion other) {
+        return new Quaternion(
+                w * other.w - x * other.x - y * other.y - z * other.z,
+                w * other.x + x * other.w + y * other.z - z * other.y,
+                w * other.y - x * other.z + y * other.w + z * other.x,
+                w * other.z + x * other.y - y * other.x + z * other.w
+        );
+    }
+
+    /**
+     * Normalizes this quaternion to unit length.
+     *
+     * @return this quaternion (for chaining)
+     */
+    public Quaternion normalize() {
+        final double len = Math.sqrt(w * w + x * x + y * y + z * z);
+        if (len > 0) {
+            w /= len;
+            x /= len;
+            y /= len;
+            z /= len;
+        }
+        return this;
+    }
+
+    /**
+     * Returns the inverse (conjugate) of this unit quaternion.
+     *
+     * <p>For a unit quaternion, the inverse equals the conjugate: (w, -x, -y, -z).
+     * This represents the opposite rotation.</p>
+     *
+     * @return a new quaternion representing the inverse rotation
+     */
+    public Quaternion invert() {
+        return new Quaternion(w, -x, -y, -z);
+    }
+
+    /**
+     * Converts this quaternion to a 3x3 rotation matrix.
+     *
+     * @return a new matrix representing this rotation
+     */
+    public Matrix3x3 toMatrix3x3() {
+        final Matrix3x3 m = new Matrix3x3();
+        copyToMatrix(m);
+        return m;
+    }
+
+    /**
+     * Copies this quaternion's rotation to an existing 3x3 matrix.
+     *
+     * <p>This method avoids allocation by reusing an existing Matrix3x3 instance.
+     * Used by Transform to avoid per-vertex allocation during rotation.</p>
+     *
+     * @param m the matrix to receive the rotation (modified in place)
+     */
+    public void copyToMatrix(final Matrix3x3 m) {
+        m.m00 = 1 - 2 * (y * y + z * z);
+        m.m01 = 2 * (x * y - w * z);
+        m.m02 = 2 * (x * z + w * y);
+
+        m.m10 = 2 * (x * y + w * z);
+        m.m11 = 1 - 2 * (x * x + z * z);
+        m.m12 = 2 * (y * z - w * x);
+
+        m.m20 = 2 * (x * z - w * y);
+        m.m21 = 2 * (y * z + w * x);
+        m.m22 = 1 - 2 * (x * x + y * y);
+    }
+
+    /**
+     * Converts this quaternion to a 3x3 rotation matrix.
+     * Alias for {@link #toMatrix3x3()} for API convenience.
+     *
+     * @return a new matrix representing this rotation
+     */
+    public Matrix3x3 toMatrix() {
+        return toMatrix3x3();
+    }
+
+    /**
+     * Extracts Euler angles (yaw, pitch, roll) from this quaternion.
+     *
+     * <p>This is the inverse of {@link #fromAngles(double, double, double)}.
+     * Returns angles in the Y-X-Z Euler order used by this engine.</p>
+     *
+     * @return array of {yaw, pitch, roll} in radians
+     */
+    public double[] toAngles() {
+        final Matrix3x3 m = toMatrix3x3();
+
+        final double pitch = -Math.asin(Math.max(-1, Math.min(1, m.m21)));
+        final double yaw = -Math.atan2(m.m20, m.m22);
+        final double roll = -Math.atan2(m.m01, m.m11);
+
+        return new double[]{yaw, pitch, roll};
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java
new file mode 100755 (executable)
index 0000000..851c6c8
--- /dev/null
@@ -0,0 +1,257 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+/**
+ * Represents a transformation in 3D space combining translation and rotation.
+ *
+ * <p>Transformations are applied in order: rotation first, then translation.</p>
+ *
+ * <p><b>Performance optimization:</b> The rotation matrix is cached and only
+ * recomputed when the rotation quaternion changes. This avoids allocating a
+ * new Matrix3x3 on every transform() call and avoids redundant quaternion-to-matrix
+ * conversions for vertices sharing the same transform.</p>
+ *
+ * <p><b>Mutability convention:</b></p>
+ * <ul>
+ *   <li><b>Imperative verbs</b> ({@code set}, {@code setTranslation}, {@code transform})
+ *       mutate this transform or the input point</li>
+ *   <li><b>{@code with}-prefixed methods</b> ({@code withTransformed})
+ *       return a new instance without modifying the original</li>
+ * </ul>
+ *
+ * <p><b>Thread safety:</b> The transform phase is single-threaded (synchronized in
+ * ShapeCollection.transformShapes()), so no synchronization is needed for the cached matrix.
+ * The matrix is computed once per Transform per frame and reused for all vertices.</p>
+ *
+ * @see Quaternion
+ * @see Point3D
+ */
+public class Transform implements Cloneable {
+
+    /**
+     * The translation applied after rotation.
+     */
+    private final Point3D translation;
+
+    /**
+     * The rotation applied before translation.
+     */
+    private final Quaternion rotation;
+
+    /**
+     * Cached rotation matrix for performance.
+     * Lazily computed when first needed and reused for subsequent transform() calls.
+     */
+    private Matrix3x3 cachedMatrix;
+
+    /**
+     * Flag indicating whether the cached matrix needs to be recomputed.
+     * Set to true when rotation is modified via set() or invalidateCache().
+     */
+    private boolean matrixDirty = true;
+
+    /**
+     * Creates a transform with no translation or rotation (identity transform).
+     */
+    public Transform() {
+        translation = new Point3D();
+        rotation = new Quaternion();
+    }
+
+    /**
+     * Creates a transform with the specified translation and no rotation.
+     *
+     * @param translation the translation
+     */
+    public Transform(final Point3D translation) {
+        this.translation = translation;
+        rotation = new Quaternion();
+    }
+
+    /**
+     * Creates a transform with the specified translation and rotation from Euler angles.
+     *
+     * @param translation the translation
+     * @param yaw         the angle around the Y axis (horizontal heading) in radians
+     * @param pitch       the angle around the X axis (vertical tilt) in radians
+     * @return a new transform with the specified translation and rotation
+     */
+    public static Transform fromAngles(final Point3D translation, final double yaw, final double pitch) {
+        return fromAngles(translation.x, translation.y, translation.z, yaw, pitch, 0);
+    }
+
+    /**
+     * Creates a transform with translation and full Euler rotation.
+     *
+     * <p>Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard
+     * Y-X-Z Euler order commonly used for object placement in 3D scenes.</p>
+     *
+     * @param x     translation X coordinate
+     * @param y     translation Y coordinate
+     * @param z     translation Z coordinate
+     * @param yaw   rotation around Y axis (horizontal heading) in radians
+     * @param pitch rotation around X axis (vertical tilt) in radians
+     * @param roll  rotation around Z axis (bank/tilt) in radians
+     * @return a new transform with the specified translation and rotation
+     */
+    public static Transform fromAngles(final double x, final double y, final double z,
+                                       final double yaw, final double pitch, final double roll) {
+        final Transform t = new Transform(new Point3D(x, y, z));
+        t.rotation.set(Quaternion.fromAngles(yaw, pitch, roll));
+        return t;
+    }
+
+    /**
+     * Creates a transform with the specified translation and rotation.
+     *
+     * @param translation the translation
+     * @param rotation    the rotation (will be cloned)
+     */
+    public Transform(final Point3D translation, final Quaternion rotation) {
+        this.translation = translation;
+        this.rotation = rotation.clone();
+    }
+
+    /**
+     * Creates a copy of this transform with cloned translation and rotation.
+     *
+     * @return a new transform with the same translation and rotation values
+     */
+    @Override
+    public Transform clone() {
+        return new Transform(translation, rotation);
+    }
+
+    /**
+     * Returns the rotation component of this transform.
+     *
+     * <p><b>Warning:</b> If you modify the returned quaternion directly, you must
+     * call {@link #invalidateCache()} afterwards to ensure the cached rotation matrix
+     * is recomputed on the next call to {@link #transform(Point3D)}.</p>
+     *
+     * @return the rotation quaternion (mutable reference)
+     */
+    public Quaternion getRotation() {
+        return rotation;
+    }
+
+    /**
+     * Invalidates the cached rotation matrix.
+     *
+     * <p>Call this method after directly modifying the rotation quaternion
+     * (obtained via {@link #getRotation()}) to ensure the matrix is recomputed
+     * on the next call to {@link #transform(Point3D)}.</p>
+     *
+     * <p>This method is automatically called by {@link #set(double, double, double, double, double, double)}.</p>
+     *
+     * @return this transform (for chaining)
+     */
+    public Transform invalidateCache() {
+        matrixDirty = true;
+        return this;
+    }
+
+    /**
+     * Returns the translation component of this transform.
+     *
+     * @return the translation point (mutable reference)
+     */
+    public Point3D getTranslation() {
+        return translation;
+    }
+
+    /**
+     * Applies this transform to a point: rotation followed by translation.
+     *
+     * <p>Uses a cached rotation matrix to avoid allocation and redundant computation.
+     * The matrix is computed once (lazily) and reused for all subsequent calls
+     * until {@link #invalidateCache()} is called.</p>
+     *
+     * @param point the point to transform (modified in place)
+     * @see #withTransformed(Point3D) for the non-mutating version that returns a new point
+     */
+    public void transform(final Point3D point) {
+        getRotationMatrix().transform(point, point);
+        point.add(translation);
+    }
+
+    /**
+     * Returns the cached rotation matrix, computing it first if the cache
+     * is dirty or not yet initialized.
+     *
+     * <p>Package-private for internal use by {@link TransformStack}.
+     * Callers must not modify the returned matrix.</p>
+     *
+     * @return the cached rotation matrix
+     */
+    Matrix3x3 getRotationMatrix() {
+        // Lazily create and cache the rotation matrix
+        if (matrixDirty || cachedMatrix == null) {
+            if (cachedMatrix == null) {
+                cachedMatrix = new Matrix3x3();
+            }
+            rotation.copyToMatrix(cachedMatrix);
+            matrixDirty = false;
+        }
+        return cachedMatrix;
+    }
+
+    /**
+     * Returns a new point with this transform applied.
+     * The original point is not modified.
+     *
+     * @param point the point to transform
+     * @return a new Point3D with the transform applied
+     * @see #transform(Point3D) for the mutating version
+     */
+    public Point3D withTransformed(final Point3D point) {
+        final Point3D result = new Point3D(point);
+        transform(result);
+        return result;
+    }
+
+    /**
+     * Sets the translation for this transform by copying the values from the given point.
+     *
+     * @param translation the translation values to copy
+     * @return this transform (for chaining)
+     */
+    public Transform setTranslation(final Point3D translation) {
+        this.translation.x = translation.x;
+        this.translation.y = translation.y;
+        this.translation.z = translation.z;
+        return this;
+    }
+
+/**
+     * Sets both translation and rotation from Euler angles.
+     *
+     * <p>Rotation order: yaw (Y) → pitch (X) → roll (Z). This is the standard
+     * Y-X-Z Euler order commonly used for object placement in 3D scenes.</p>
+     *
+     * <p>This method invalidates the cached rotation matrix.</p>
+     *
+     * @param x     translation X coordinate
+     * @param y     translation Y coordinate
+     * @param z     translation Z coordinate
+     * @param yaw   rotation around Y axis (horizontal heading) in radians
+     * @param pitch rotation around X axis (vertical tilt) in radians
+     * @param roll  rotation around Z axis (bank/tilt) in radians
+     * @return this transform for chaining
+     */
+    public Transform set(final double x, final double y, final double z,
+                         final double yaw, final double pitch, final double roll) {
+        translation.x = x;
+        translation.y = y;
+        translation.z = z;
+        rotation.set(Quaternion.fromAngles(yaw, pitch, roll));
+        matrixDirty = true;
+        return this;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java
new file mode 100644 (file)
index 0000000..58023c4
--- /dev/null
@@ -0,0 +1,222 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+/**
+ * Stack of transforms applied to points during rendering.
+ *
+ * <p>Transforms are applied in reverse order (last added is applied first).
+ * This supports hierarchical scene graphs where child objects are positioned
+ * relative to their parent objects.</p>
+ *
+ * <p><b>Example:</b></p>
+ * <pre>
+ * There is a ship in the sea. The ship moves along the sea, and every object
+ * on the ship moves with it. Inside the ship there is a car. The car moves
+ * along the ship, and every object on the car moves with it.
+ *
+ * To calculate the world position of an object inside the car:
+ * 1. Apply an object's position relative to the car
+ * 2. Apply the car's position relative to the ship
+ * 3. Apply ship's position relative to the world
+ * </pre>
+ *
+ * <p><b>Implementation: eager composition.</b> Every transform is a rigid
+ * motion (rotation then translation), and compositions of rigid motions are
+ * closed and associative, so the whole stack collapses into a single
+ * equivalent transform. The stack maintains the composed rotation matrix and
+ * translation for each level: {@link #addTransform} composes the pushed
+ * transform with the previous level's composite, and {@link #transform}
+ * applies only the top-level composite. Per-point cost is one 3x3
+ * matrix-vector multiply plus one addition, independent of stack depth.
+ * {@link #dropTransform} restores the parent composite for free.</p>
+ *
+ * <p><b>Contract:</b> composition is a snapshot taken at push time. Mutating
+ * a transform after pushing it has no effect on the stack until it is
+ * dropped and pushed again. The traversal rebuilds the stack every frame,
+ * so frame-to-frame changes are always picked up.</p>
+ *
+ * @see Transform
+ */
+public class TransformStack {
+
+    /**
+     * Maximum nesting depth of transforms.
+     * Fixed size for efficiency to avoid memory allocation during rendering.
+     */
+    private static final int MAX_DEPTH = 100;
+
+    /**
+     * Composed row-major 3x3 rotation matrices, 9 doubles per stack level.
+     * Level i holds the composition of all transforms pushed so far, in
+     * application order (level i's own transform applied first, level 0 last).
+     */
+    private final double[] rotations = new double[MAX_DEPTH * 9];
+
+    /**
+     * Composed translations, 3 doubles per stack level.
+     */
+    private final double[] translations = new double[MAX_DEPTH * 3];
+
+    /**
+     * The current number of transforms in the stack.
+     */
+    private int transformsCount = 0;
+
+    /**
+     * Creates a new empty transform stack.
+     */
+    public TransformStack() {
+    }
+
+    /**
+     * Creates a copy of another transform stack, duplicating its composed
+     * per-level transforms. Used to give each parallel transform worker its
+     * own stack preloaded with the same camera/parent state.
+     *
+     * @param source the stack to copy
+     */
+    public TransformStack(final TransformStack source) {
+        loadFrom(source);
+    }
+
+    /**
+     * Reinitializes this stack as a copy of {@code source} without
+     * allocating: the fixed-size arrays are reused. Used by the parallel
+     * transform coordinator to hand pooled stacks to chunk tasks.
+     *
+     * @param source the stack to copy
+     */
+    public void loadFrom(final TransformStack source) {
+        transformsCount = source.transformsCount;
+        System.arraycopy(source.rotations, 0, rotations, 0, transformsCount * 9);
+        System.arraycopy(source.translations, 0, translations, 0, transformsCount * 3);
+    }
+
+    /**
+     * Pushes a transform onto the stack, composing it with the current
+     * top-level composite.
+     *
+     * <p>If the previous composite is (Rp, tp) and the pushed transform is
+     * (Rn, tn), the new composite is R' = Rp * Rn, t' = Rp * tn + tp —
+     * the pushed transform applies first, then the previous composite.</p>
+     *
+     * @param transform the transform to push (snapshotted at push time)
+     */
+    public void addTransform(final Transform transform) {
+        final int i = transformsCount;
+        final int r = i * 9;
+        final int v = i * 3;
+
+        final Matrix3x3 rm = transform.getRotationMatrix();
+        final Point3D t = transform.getTranslation();
+
+        if (i == 0) {
+            rotations[r]     = rm.m00;
+            rotations[r + 1] = rm.m01;
+            rotations[r + 2] = rm.m02;
+            rotations[r + 3] = rm.m10;
+            rotations[r + 4] = rm.m11;
+            rotations[r + 5] = rm.m12;
+            rotations[r + 6] = rm.m20;
+            rotations[r + 7] = rm.m21;
+            rotations[r + 8] = rm.m22;
+            translations[v]     = t.x;
+            translations[v + 1] = t.y;
+            translations[v + 2] = t.z;
+        } else {
+            final int pr = r - 9;
+            final int pv = v - 3;
+
+            final double a00 = rotations[pr];
+            final double a01 = rotations[pr + 1];
+            final double a02 = rotations[pr + 2];
+            final double a10 = rotations[pr + 3];
+            final double a11 = rotations[pr + 4];
+            final double a12 = rotations[pr + 5];
+            final double a20 = rotations[pr + 6];
+            final double a21 = rotations[pr + 7];
+            final double a22 = rotations[pr + 8];
+
+            rotations[r]     = a00 * rm.m00 + a01 * rm.m10 + a02 * rm.m20;
+            rotations[r + 1] = a00 * rm.m01 + a01 * rm.m11 + a02 * rm.m21;
+            rotations[r + 2] = a00 * rm.m02 + a01 * rm.m12 + a02 * rm.m22;
+            rotations[r + 3] = a10 * rm.m00 + a11 * rm.m10 + a12 * rm.m20;
+            rotations[r + 4] = a10 * rm.m01 + a11 * rm.m11 + a12 * rm.m21;
+            rotations[r + 5] = a10 * rm.m02 + a11 * rm.m12 + a12 * rm.m22;
+            rotations[r + 6] = a20 * rm.m00 + a21 * rm.m10 + a22 * rm.m20;
+            rotations[r + 7] = a20 * rm.m01 + a21 * rm.m11 + a22 * rm.m21;
+            rotations[r + 8] = a20 * rm.m02 + a21 * rm.m12 + a22 * rm.m22;
+
+            translations[v]     = a00 * t.x + a01 * t.y + a02 * t.z + translations[pv];
+            translations[v + 1] = a10 * t.x + a11 * t.y + a12 * t.z + translations[pv + 1];
+            translations[v + 2] = a20 * t.x + a21 * t.y + a22 * t.z + translations[pv + 2];
+        }
+        transformsCount++;
+    }
+
+    /**
+     * Clears all transforms from the stack.
+     */
+    public void clear() {
+        transformsCount = 0;
+    }
+
+    /**
+     * Pops the most recently added transform from the stack. The parent
+     * level's composite is restored automatically.
+     */
+    public void dropTransform() {
+        transformsCount--;
+    }
+
+    /**
+     * Transforms a point through the whole stack by applying the top-level
+     * composed transform. Cost is independent of stack depth.
+     *
+     * @param coordinate the input coordinate (not modified)
+     * @param result     the output coordinate (receives transformed result)
+     */
+    public void transform(final Point3D coordinate, final Point3D result) {
+        if (transformsCount == 0) {
+            result.clone(coordinate);
+            return;
+        }
+        final int r = (transformsCount - 1) * 9;
+        final int v = (transformsCount - 1) * 3;
+        final double x = coordinate.x;
+        final double y = coordinate.y;
+        final double z = coordinate.z;
+        result.x = rotations[r] * x + rotations[r + 1] * y + rotations[r + 2] * z + translations[v];
+        result.y = rotations[r + 3] * x + rotations[r + 4] * y + rotations[r + 5] * z + translations[v + 1];
+        result.z = rotations[r + 6] * x + rotations[r + 7] * y + rotations[r + 8] * z + translations[v + 2];
+    }
+
+    /**
+     * Copies the fully composed top-level transform (rotations 0..8, then
+     * translations 0..2) into {@code out} (length &ge; 12), for bulk
+     * loops that apply the same matrix to thousands of vertices
+     * ({@code TriangleMeshBlock}). Identity when the stack is empty.
+     * Applying it with the same expression order as
+     * {@link #transform(Point3D, Point3D)} yields bit-identical results.
+     *
+     * @param out destination array, length at least 12
+     */
+    public void getTopTransform(final double[] out) {
+        if (transformsCount == 0) {
+            out[0] = 1; out[1] = 0; out[2] = 0;
+            out[3] = 0; out[4] = 1; out[5] = 0;
+            out[6] = 0; out[7] = 0; out[8] = 1;
+            out[9] = 0; out[10] = 0; out[11] = 0;
+            return;
+        }
+        final int r = (transformsCount - 1) * 9;
+        final int v = (transformsCount - 1) * 3;
+        System.arraycopy(rotations, r, out, 0, 9);
+        System.arraycopy(translations, v, out, 9, 3);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Vertex.java
new file mode 100644 (file)
index 0000000..0eb8bcd
--- /dev/null
@@ -0,0 +1,293 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+
+/**
+ * A vertex in 3D space with transformation and screen projection support.
+ *
+ * <p>A vertex represents a corner point of a polygon or polyhedron. In addition to
+ * the 3D coordinate, it stores the transformed position (relative to viewer) and
+ * the projected screen coordinates for rendering.</p>
+ *
+ * <p><b>Coordinate spaces:</b></p>
+ * <ul>
+ *   <li>{@link #coordinate} - Original position in local/model space</li>
+ *   <li>{@link #transformedCoordinate} - Position relative to viewer (camera space)</li>
+ *   <li>{@link #onScreenCoordinate} - 2D screen position after perspective projection</li>
+ * </ul>
+ *
+ * <p><b>Example:</b></p>
+ * <pre>{@code
+ * Vertex v = new Vertex(new Point3D(10, 20, 30));
+ * v.calculateLocationRelativeToViewer(transformStack, renderContext);
+ * if (v.transformedCoordinate.z > 0) {
+ *     // Vertex is in front of the camera
+ * }
+ * }</pre>
+ *
+ * @see Point3D
+ * @see TransformStack
+ */
+public class Vertex {
+
+    /**
+     * Vertex coordinate in local/model 3D space.
+     */
+    public Point3D coordinate;
+
+    /**
+     * Vertex coordinate relative to the viewer after transformation (camera
+     * space), per buffer slot. Slot parity lets the transform phase of the
+     * NEXT frame write slot B while the paint phase of the current frame
+     * still reads slot A (double-buffered pipeline). Never access directly:
+     * use {@link #transformedCoordinate(RenderingContext)}.
+     */
+    private final Point3D transformedCoordinate0 = new Point3D();
+    private final Point3D transformedCoordinate1 = new Point3D();
+    private final Point3D transformedCoordinate2 = new Point3D();
+
+    /**
+     * Vertex position on screen in pixels, per buffer slot.
+     * Use {@link #onScreenCoordinate(RenderingContext)}.
+     */
+    private final Point2D onScreenCoordinate0 = new Point2D();
+    private final Point2D onScreenCoordinate1 = new Point2D();
+    private final Point2D onScreenCoordinate2 = new Point2D();
+
+    /**
+     * Texture coordinate for UV mapping (optional).
+     */
+    public Point2D textureCoordinate;
+
+    /**
+     * Normal vector for this vertex (optional).
+     * Used by CSG operations for smooth interpolation during polygon splitting.
+     * Null for non-CSG usage; existing rendering code ignores this field.
+     */
+    public Point3D normal;
+
+
+    /**
+     * The transform cycle when each slot was last transformed (for
+     * caching). Keyed by {@link RenderingContext#transformCycleId}, NOT
+     * by frameNumber: in stereo mode both eye passes share the same
+     * parity context and therefore the same frameNumber, while using
+     * different slots — a frameNumber-based key lets one eye's pass
+     * falsely hit the cache entry the other eye wrote for the same slot
+     * an odd number of passes earlier, leaving opposite-eye (wrong
+     * viewport-offset) screen coordinates in the slot: the affected eye
+     * paints fully off-viewport, i.e. a black half-frame (observed as
+     * violent left/right flashing, 2026-09-09). transformCycleId is
+     * unique per pass, so a hit always means "already transformed within
+     * THIS pass" — the only correct dedupe semantics.
+     */
+    private long lastTransformCycle0 = -1;
+    private long lastTransformCycle1 = -1;
+    private long lastTransformCycle2 = -1;
+
+    /**
+     * Creates a vertex at the origin (0, 0, 0) with no texture coordinate.
+     */
+    public Vertex() {
+        this(new Point3D());
+    }
+
+    /**
+     * Creates a vertex at the specified position with no texture coordinate.
+     *
+     * @param coordinate the 3D position of this vertex
+     */
+    public Vertex(final Point3D coordinate) {
+        this(coordinate, null);
+    }
+
+    /**
+     * Creates a vertex at the specified position with an optional texture coordinate.
+     *
+     * @param coordinate        the 3D position of this vertex
+     * @param textureCoordinate the UV texture coordinate, or {@code null} for none
+     */
+    public Vertex(final Point3D coordinate, final Point2D textureCoordinate) {
+        this.coordinate = coordinate;
+        this.textureCoordinate = textureCoordinate;
+    }
+
+    /**
+     * Returns the camera-space coordinate for the rendering context's buffer
+     * slot. Valid only after this vertex was transformed for that slot's
+     * current frame.
+     *
+     * @param renderContext the rendering context (selects the buffer slot)
+     * @return the transformed coordinate (camera space) for the active slot
+     */
+    public Point3D transformedCoordinate(final RenderingContext renderContext) {
+        // Dual fields, not a slot array: measured 2026-09-05 (400-sphere
+        // scene) that array indexing adds a second dependent load per access
+        // and cost ~60% of transform phase time; a perfectly-predicted
+        // branch + direct field load is free.
+        final int slot = renderContext.vertexSlot;
+        return slot == 0 ? transformedCoordinate0
+                : slot == 1 ? transformedCoordinate1 : transformedCoordinate2;
+    }
+
+    /**
+     * Returns the screen-space position for the rendering context's buffer
+     * slot.
+     *
+     * @param renderContext the rendering context (selects the buffer slot)
+     * @return the on-screen coordinate (pixels) for the active slot
+     */
+    public Point2D onScreenCoordinate(final RenderingContext renderContext) {
+        final int slot = renderContext.vertexSlot;
+        return slot == 0 ? onScreenCoordinate0
+                : slot == 1 ? onScreenCoordinate1 : onScreenCoordinate2;
+    }
+
+
+    /**
+     * Transforms this vertex from model space to screen space.
+     *
+     * <p>This method applies the transform stack to compute the vertex position
+     * relative to the viewer, then projects it to 2D screen coordinates.
+     * Results are cached per-frame per-slot to avoid redundant calculations.</p>
+     *
+     * @param transforms    the transform stack to apply (world-to-camera transforms)
+     * @param renderContext the rendering context providing projection parameters
+     */
+    public void calculateLocationRelativeToViewer(final TransformStack transforms,
+                                                  final RenderingContext renderContext) {
+
+        final Point3D transformedCoordinate;
+        final Point2D onScreenCoordinate;
+        switch (renderContext.vertexSlot) {
+            case 0:
+                if (lastTransformCycle0 == renderContext.transformCycleId)
+                    return;
+                lastTransformCycle0 = renderContext.transformCycleId;
+                transformedCoordinate = transformedCoordinate0;
+                onScreenCoordinate = onScreenCoordinate0;
+                break;
+            case 1:
+                if (lastTransformCycle1 == renderContext.transformCycleId)
+                    return;
+                lastTransformCycle1 = renderContext.transformCycleId;
+                transformedCoordinate = transformedCoordinate1;
+                onScreenCoordinate = onScreenCoordinate1;
+                break;
+            default:
+                if (lastTransformCycle2 == renderContext.transformCycleId)
+                    return;
+                lastTransformCycle2 = renderContext.transformCycleId;
+                transformedCoordinate = transformedCoordinate2;
+                onScreenCoordinate = onScreenCoordinate2;
+                break;
+        }
+        transforms.transform(coordinate, transformedCoordinate);
+        onScreenCoordinate.x = ((transformedCoordinate.x / transformedCoordinate.z) * renderContext.projectionScale);
+        onScreenCoordinate.y = ((transformedCoordinate.y / transformedCoordinate.z) * renderContext.projectionScale);
+        onScreenCoordinate.add(renderContext.centerCoordinate);
+        onScreenCoordinate.x += renderContext.stereoViewportOffsetX;
+    }
+
+    /**
+     * Writes a camera-space position directly into this vertex's slot state
+     * and projects it to screen coordinates, bypassing the transform stack.
+     *
+     * <p>Used by near-plane clipping ({@code AbstractCoordinateShape}), which
+     * creates intersection vertices that exist ONLY in camera space — there
+     * is no model-space coordinate to transform. The given z must be &gt; 0
+     * (clip against the near plane guarantees z == nearPlaneDistance).</p>
+     *
+     * @param x             camera-space X
+     * @param y             camera-space Y
+     * @param z             camera-space Z (depth in front of viewer, &gt; 0)
+     * @param renderContext the rendering context (selects the buffer slot and
+     *                      provides projection parameters)
+     */
+    public void setCameraSpaceCoordinate(final double x, final double y, final double z,
+                                         final RenderingContext renderContext) {
+        final Point3D transformedCoordinate;
+        final Point2D onScreenCoordinate;
+        switch (renderContext.vertexSlot) {
+            case 0:
+                transformedCoordinate = transformedCoordinate0;
+                onScreenCoordinate = onScreenCoordinate0;
+                break;
+            case 1:
+                transformedCoordinate = transformedCoordinate1;
+                onScreenCoordinate = onScreenCoordinate1;
+                break;
+            default:
+                transformedCoordinate = transformedCoordinate2;
+                onScreenCoordinate = onScreenCoordinate2;
+                break;
+        }
+        transformedCoordinate.x = x;
+        transformedCoordinate.y = y;
+        transformedCoordinate.z = z;
+        onScreenCoordinate.x = ((x / z) * renderContext.projectionScale);
+        onScreenCoordinate.y = ((y / z) * renderContext.projectionScale);
+        onScreenCoordinate.add(renderContext.centerCoordinate);
+        onScreenCoordinate.x += renderContext.stereoViewportOffsetX;
+    }
+
+    // ========== CSG support methods ==========
+
+    /**
+     * Creates a deep copy of this vertex.
+     * Clones the coordinate, normal (if present), and texture coordinate (if present).
+     * The transformedCoordinate and onScreenCoordinate are not cloned (they are computed per-frame).
+     *
+     * @return a new Vertex with cloned data
+     */
+    public Vertex clone() {
+        final Vertex result = new Vertex(new Point3D(coordinate),
+                textureCoordinate != null ? new Point2D(textureCoordinate) : null);
+        if (normal != null) {
+            result.normal = new Point3D(normal);
+        }
+        return result;
+    }
+
+    /**
+     * Flips the orientation of this vertex by negating the normal vector.
+     * Called when the orientation of a polygon is flipped during CSG operations.
+     * If normal is null, this method does nothing.
+     */
+    public void flip() {
+        if (normal != null) {
+            normal = normal.withNegated();
+        }
+    }
+
+    /**
+     * Creates a new vertex between this vertex and another by linearly interpolating
+     * all properties using parameter t.
+     *
+     * <p>Interpolates: position, normal (if present), and texture coordinate (if present).</p>
+     *
+     * @param other the other vertex to interpolate towards
+     * @param t     the interpolation parameter (0 = this vertex, 1 = other vertex)
+     * @return a new Vertex representing the interpolated position
+     */
+    public Vertex interpolate(final Vertex other, final double t) {
+        final Vertex result = new Vertex(
+                coordinate.interpolate(other.coordinate, t),
+                (textureCoordinate != null && other.textureCoordinate != null)
+                        ? new Point2D(
+                        textureCoordinate.x + (other.textureCoordinate.x - textureCoordinate.x) * t,
+                        textureCoordinate.y + (other.textureCoordinate.y - textureCoordinate.y) * t)
+                        : null
+        );
+        if (normal != null && other.normal != null) {
+            result.normal = normal.interpolate(other.normal, t);
+        }
+        return result;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/package-info.java
new file mode 100644 (file)
index 0000000..3d7ab5e
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ * <p>
+ * Math that is needed for the project.
+ */
+
+package eu.svjatoslav.aukio.e3d.math;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/package-info.java
new file mode 100644 (file)
index 0000000..b8092d1
--- /dev/null
@@ -0,0 +1,7 @@
+/**
+ * This is root package for 3D engine. Since package name cannot start with a digit, it is named "e3d" instead,
+ * which stands for "Engine 3D".
+ */
+
+package eu.svjatoslav.aukio.e3d;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/IntegerPoint.java
new file mode 100644 (file)
index 0000000..01acee1
--- /dev/null
@@ -0,0 +1,39 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree;
+
+/**
+ * Point in 3D space with integer coordinates. Used for octree voxel positions.
+ */
+public class IntegerPoint
+{
+    /** X coordinate. */
+    public int x;
+    /** Y coordinate. */
+    public int y;
+    /** Z coordinate. */
+    public int z = 0;
+
+    /**
+     * Creates a point at the origin (0, 0, 0).
+     */
+    public IntegerPoint()
+    {
+    }
+
+    /**
+     * Creates a point with the specified coordinates.
+     *
+     * @param x the X coordinate
+     * @param y the Y coordinate
+     * @param z the Z coordinate
+     */
+    public IntegerPoint(final int x, final int y, final int z)
+    {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/OctreeVolume.java
new file mode 100755 (executable)
index 0000000..5ae69d8
--- /dev/null
@@ -0,0 +1,1102 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+import static java.lang.Integer.max;
+import static java.lang.Integer.min;
+
+/**
+ * Sparse voxel octree for 3D volume storage and ray tracing.
+ *
+ * <p>The octree represents a 3D volume with three cell types:</p>
+ * <ul>
+ *   <li><b>UNUSED</b> - Empty cell, not yet allocated</li>
+ *   <li><b>SOLID</b> - Contains color and illumination data</li>
+ *   <li><b>CLUSTER</b> - Contains pointers to 8 child cells (for subdivision)</li>
+ * </ul>
+ *
+ * <p>Cell data is stored in parallel arrays ({@code cell1} through {@code cell8})
+ * for memory efficiency. Each array stores different aspects of cell data.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RayTracer
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray
+ */
+public class OctreeVolume {
+
+    /** Return value indicating no intersection during ray tracing. */
+    public static final int TRACE_NO_HIT = -1;
+
+    /** Cell state marker for solid cells. */
+    private static final int CELL_STATE_SOLID = -2;
+
+    /** Cell state marker for unused/empty cells. */
+    private static final int CELL_STATE_UNUSED = -1;
+
+    /** Cell data array 1: stores cell state and first child pointer. */
+    public int[] cell1;
+    /** Cell data array 2: stores color values. */
+    public int[] cell2;
+    /** Cell data array 3: stores illumination values. */
+    public int[] cell3;
+    /** Cell data array 4: stores child pointer 4. */
+    public int[] cell4;
+    /** Cell data array 5: stores child pointer 5. */
+    public int[] cell5;
+    /** Cell data array 6: stores child pointer 6. */
+    public int[] cell6;
+    /** Cell data array 7: stores child pointer 7. */
+    public int[] cell7;
+    /** Cell data array 8: stores child pointer 8. */
+    public int[] cell8;
+
+    /**
+     * Pointer to the next unused cell in the allocation buffer.
+     */
+    public int cellAllocationPointer = 0;
+
+    /** Number of currently allocated cells. */
+    public int usedCellsCount = 0;
+
+    /** Size of the root (master) cell in world units. */
+    public int masterCellSize;
+
+    /**
+     * Creates a new octree volume with default buffer size (1.5M cells)
+     * and master cell size of 256*64 units.
+     */
+    public OctreeVolume() {
+        initWorld(1500000, 256 * 64);
+    }
+
+    /**
+     * Subdivides a solid cell into 8 child cells, each with the same color and illumination.
+     *
+     * @param pointer the cell to break up
+     */
+    public void breakSolidCell(final int pointer) {
+        final int color = getCellColor(pointer);
+        final int illumination = getCellIllumination(pointer);
+
+        cell1[pointer] = makeNewCell(color, illumination);
+        cell2[pointer] = makeNewCell(color, illumination);
+        cell3[pointer] = makeNewCell(color, illumination);
+        cell4[pointer] = makeNewCell(color, illumination);
+        cell5[pointer] = makeNewCell(color, illumination);
+        cell6[pointer] = makeNewCell(color, illumination);
+        cell7[pointer] = makeNewCell(color, illumination);
+        cell8[pointer] = makeNewCell(color, illumination);
+    }
+
+    /**
+     * Clears the cell.
+     * @param pointer Pointer to the cell.
+     */
+    public void clearCell(final int pointer) {
+        cell1[pointer] = 0;
+        cell2[pointer] = 0;
+        cell3[pointer] = 0;
+        cell4[pointer] = 0;
+
+        cell5[pointer] = 0;
+        cell6[pointer] = 0;
+        cell7[pointer] = 0;
+        cell8[pointer] = 0;
+    }
+
+    /**
+     * Marks a cell as deleted and returns it to the unused pool.
+     *
+     * @param cellPointer the cell to delete
+     */
+    public void deleteCell(final int cellPointer) {
+        clearCell(cellPointer);
+        cell1[cellPointer] = CELL_STATE_UNUSED;
+        usedCellsCount--;
+    }
+
+    /**
+     * Tests whether a ray intersects with a cubic region.
+     *
+     * @param cubeX    the X center of the cube
+     * @param cubeY    the Y center of the cube
+     * @param cubeZ    the Z center of the cube
+     * @param cubeSize the half-size of the cube
+     * @param r        the ray to test
+     * @return intersection type code, or 0 if no intersection
+     */
+    public int doesIntersect(final int cubeX, final int cubeY, final int cubeZ,
+                             final int cubeSize, final Ray r) {
+
+        // ray starts inside the cube
+        if ((cubeX - cubeSize) < r.origin.x)
+            if ((cubeX + cubeSize) > r.origin.x)
+                if ((cubeY - cubeSize) < r.origin.y)
+                    if ((cubeY + cubeSize) > r.origin.y)
+                        if ((cubeZ - cubeSize) < r.origin.z)
+                            if ((cubeZ + cubeSize) > r.origin.z) {
+                                r.hitPoint = r.origin.clone();
+                                return 1;
+                            }
+        // back face
+        if (r.direction.z > 0)
+            if ((cubeZ - cubeSize) > r.origin.z) {
+                final double mult = ((cubeZ - cubeSize) - r.origin.z) / r.direction.z;
+                final double hitX = (r.direction.x * mult) + r.origin.x;
+                if ((cubeX - cubeSize) < hitX)
+                    if ((cubeX + cubeSize) > hitX) {
+                        final double hitY = (r.direction.y * mult) + r.origin.y;
+                        if ((cubeY - cubeSize) < hitY)
+                            if ((cubeY + cubeSize) > hitY) {
+                                r.hitPoint = new Point3D(hitX, hitY, cubeZ
+                                        - cubeSize);
+                                return 2;
+                            }
+                    }
+            }
+
+        // up face
+        if (r.direction.y > 0)
+            if ((cubeY - cubeSize) > r.origin.y) {
+                final double mult = ((cubeY - cubeSize) - r.origin.y) / r.direction.y;
+                final double hitX = (r.direction.x * mult) + r.origin.x;
+                if ((cubeX - cubeSize) < hitX)
+                    if ((cubeX + cubeSize) > hitX) {
+                        final double hitZ = (r.direction.z * mult) + r.origin.z;
+                        if ((cubeZ - cubeSize) < hitZ)
+                            if ((cubeZ + cubeSize) > hitZ) {
+                                r.hitPoint = new Point3D(hitX, cubeY - cubeSize,
+                                        hitZ);
+                                return 3;
+                            }
+                    }
+            }
+
+        // left face
+        if (r.direction.x > 0)
+            if ((cubeX - cubeSize) > r.origin.x) {
+                final double mult = ((cubeX - cubeSize) - r.origin.x) / r.direction.x;
+                final double hitY = (r.direction.y * mult) + r.origin.y;
+                if ((cubeY - cubeSize) < hitY)
+                    if ((cubeY + cubeSize) > hitY) {
+                        final double hitZ = (r.direction.z * mult) + r.origin.z;
+                        if ((cubeZ - cubeSize) < hitZ)
+                            if ((cubeZ + cubeSize) > hitZ) {
+                                r.hitPoint = new Point3D(cubeX - cubeSize, hitY,
+                                        hitZ);
+                                return 4;
+                            }
+                    }
+            }
+
+        // front face
+        if (r.direction.z < 0)
+            if ((cubeZ + cubeSize) < r.origin.z) {
+                final double mult = ((cubeZ + cubeSize) - r.origin.z) / r.direction.z;
+                final double hitX = (r.direction.x * mult) + r.origin.x;
+                if ((cubeX - cubeSize) < hitX)
+                    if ((cubeX + cubeSize) > hitX) {
+                        final double hitY = (r.direction.y * mult) + r.origin.y;
+                        if ((cubeY - cubeSize) < hitY)
+                            if ((cubeY + cubeSize) > hitY) {
+                                r.hitPoint = new Point3D(hitX, hitY, cubeZ
+                                        + cubeSize);
+                                return 5;
+                            }
+                    }
+            }
+
+        // down face
+        if (r.direction.y < 0)
+            if ((cubeY + cubeSize) < r.origin.y) {
+                final double mult = ((cubeY + cubeSize) - r.origin.y) / r.direction.y;
+                final double hitX = (r.direction.x * mult) + r.origin.x;
+                if ((cubeX - cubeSize) < hitX)
+                    if ((cubeX + cubeSize) > hitX) {
+                        final double hitZ = (r.direction.z * mult) + r.origin.z;
+                        if ((cubeZ - cubeSize) < hitZ)
+                            if ((cubeZ + cubeSize) > hitZ) {
+                                r.hitPoint = new Point3D(hitX, cubeY + cubeSize,
+                                        hitZ);
+                                return 6;
+                            }
+                    }
+            }
+
+        // right face
+        if (r.direction.x < 0)
+            if ((cubeX + cubeSize) < r.origin.x) {
+                final double mult = ((cubeX + cubeSize) - r.origin.x) / r.direction.x;
+                final double hitY = (r.direction.y * mult) + r.origin.y;
+                if ((cubeY - cubeSize) < hitY)
+                    if ((cubeY + cubeSize) > hitY) {
+                        final double hitZ = (r.direction.z * mult) + r.origin.z;
+                        if ((cubeZ - cubeSize) < hitZ)
+                            if ((cubeZ + cubeSize) > hitZ) {
+                                r.hitPoint = new Point3D(cubeX + cubeSize, hitY,
+                                        hitZ);
+                                return 7;
+                            }
+                    }
+            }
+        return 0;
+    }
+
+    /**
+     * Fills a 3D rectangular region with solid cells of the given color.
+     *
+     * @param p1    one corner of the rectangle
+     * @param p2    the opposite corner of the rectangle
+     * @param color the color to fill with
+     */
+    public void fillRectangle(IntegerPoint p1, IntegerPoint p2, Color color) {
+
+        int x1 = min(p1.x, p2.x);
+        int x2 = max(p1.x, p2.x);
+        int y1 = min(p1.y, p2.y);
+        int y2 = max(p1.y, p2.y);
+        int z1 = min(p1.z, p2.z);
+        int z2 = max(p1.z, p2.z);
+
+        for (int x = x1; x <= x2; x++)
+            for (int y = y1; y <= y2; y++)
+                for (int z = z1; z <= z2; z++)
+                    putCell(x, y, z, 0, 0, 0, masterCellSize, 0, color);
+    }
+
+    /**
+     * Returns the color value stored in a solid cell.
+     *
+     * @param pointer the cell pointer
+     * @return the packed RGB color value
+     */
+    public int getCellColor(final int pointer) {
+        return cell2[pointer];
+    }
+
+    /**
+     * Returns the illumination value stored in a solid cell.
+     *
+     * @param pointer the cell pointer
+     * @return the packed RGB illumination value
+     */
+    public int getCellIllumination(final int pointer) {
+        return cell3[pointer];
+    }
+
+    /**
+     * Initializes the octree storage arrays with the specified buffer size and root cell size.
+     *
+     * @param bufferLength   the number of cells to allocate space for
+     * @param masterCellSize the size of the root cell in world units
+     */
+    public void initWorld(final int bufferLength, final int masterCellSize) {
+        // System.out.println("Initializing new world");
+
+        // initialize world storage buffer
+        this.masterCellSize = masterCellSize;
+
+        cell1 = new int[bufferLength];
+        cell2 = new int[bufferLength];
+        cell3 = new int[bufferLength];
+        cell4 = new int[bufferLength];
+
+        cell5 = new int[bufferLength];
+        cell6 = new int[bufferLength];
+        cell7 = new int[bufferLength];
+        cell8 = new int[bufferLength];
+
+        for (int i = 0; i < bufferLength; i++)
+            cell1[i] = CELL_STATE_UNUSED;
+
+        // initialize master cell
+        clearCell(0);
+    }
+
+    /**
+     * Checks if the cell at the given pointer is a solid (leaf) cell.
+     *
+     * @param pointer the cell pointer to check
+     * @return {@code true} if the cell is solid
+     */
+    public boolean isCellSolid(final int pointer) {
+        return cell1[pointer] == CELL_STATE_SOLID;
+    }
+
+    /**
+     * Scans cells arrays and returns pointer to found unused cell.
+     * @return pointer to found unused cell
+     */
+    public int getNewCellPointer() {
+        while (true) {
+            // ensure that cell allocation pointer is in bounds
+            if (cellAllocationPointer >= cell1.length)
+                cellAllocationPointer = 0;
+
+            if (cell1[cellAllocationPointer] == CELL_STATE_UNUSED) {
+                // unused cell found
+                clearCell(cellAllocationPointer);
+
+                usedCellsCount++;
+                return cellAllocationPointer;
+            } else
+                cellAllocationPointer++;
+        }
+    }
+
+    /**
+     * Allocates a new solid cell with the given color and illumination.
+     *
+     * @param color        the color value for the new cell
+     * @param illumination the illumination value for the new cell
+     * @return the pointer to the newly allocated cell
+     */
+    public int makeNewCell(final int color, final int illumination) {
+        final int pointer = getNewCellPointer();
+        markCellAsSolid(pointer);
+        setCellColor(pointer, color);
+        setCellIllumination(pointer, illumination);
+        return pointer;
+    }
+
+    /**
+     * Mark cell as solid.
+     *
+     * @param pointer pointer to cell
+     */
+    public void markCellAsSolid(final int pointer) {
+        cell1[pointer] = CELL_STATE_SOLID;
+    }
+
+    /**
+     * Stores a voxel at the given world coordinates with the specified color.
+     *
+     * @param x     the X coordinate
+     * @param y     the Y coordinate
+     * @param z     the Z coordinate
+     * @param color the color of the voxel
+     */
+    public void putCell(final int x, final int y, final int z, final Color color) {
+        putCell(x, y, z, 0, 0, 0, masterCellSize, 0, color);
+    }
+
+    private void putCell(final int x, final int y, final int z,
+                         final int cellX, final int cellY, final int cellZ,
+                         final int cellSize, final int cellPointer, final Color color) {
+
+        if (cellSize > 1) {
+
+            // if case of big cell
+            if (isCellSolid(cellPointer)) {
+
+                // if cell is already a needed color, do nothing
+                if (getCellColor(cellPointer) == color.toInt())
+                    return;
+
+                // otherwise break cell up
+                breakSolidCell(cellPointer);
+
+                // continue, as if it is cluster now
+            }
+
+            // decide which subcube to use
+            int[] subCubeArray;
+            int subX, subY, subZ;
+
+            if (x > cellX) {
+                subX = (cellSize / 2) + cellX;
+                if (y > cellY) {
+                    subY = (cellSize / 2) + cellY;
+                    if (z > cellZ) {
+                        subZ = (cellSize / 2) + cellZ;
+                        // 7
+                        subCubeArray = cell7;
+                    } else {
+                        subZ = (-cellSize / 2) + cellZ;
+                        // 3
+                        subCubeArray = cell3;
+                    }
+                } else {
+                    subY = (-cellSize / 2) + cellY;
+                    if (z > cellZ) {
+                        subZ = (cellSize / 2) + cellZ;
+                        // 6
+                        subCubeArray = cell6;
+                    } else {
+                        subZ = (-cellSize / 2) + cellZ;
+                        // 2
+                        subCubeArray = cell2;
+                    }
+                }
+            } else {
+                subX = (-cellSize / 2) + cellX;
+                if (y > cellY) {
+                    subY = (cellSize / 2) + cellY;
+                    if (z > cellZ) {
+                        subZ = (cellSize / 2) + cellZ;
+                        // 8
+                        subCubeArray = cell8;
+                    } else {
+                        subZ = (-cellSize / 2) + cellZ;
+                        // 4
+                        subCubeArray = cell4;
+                    }
+                } else {
+                    subY = (-cellSize / 2) + cellY;
+                    if (z > cellZ) {
+                        subZ = (cellSize / 2) + cellZ;
+                        // 5
+                        subCubeArray = cell5;
+                    } else {
+                        subZ = (-cellSize / 2) + cellZ;
+                        // 1
+                        subCubeArray = cell1;
+                    }
+                }
+            }
+
+            int subCubePointer;
+            if (subCubeArray[cellPointer] == 0) {
+                // create empty cluster
+                subCubePointer = getNewCellPointer();
+                subCubeArray[cellPointer] = subCubePointer;
+            } else
+                subCubePointer = subCubeArray[cellPointer];
+
+            putCell(x, y, z, subX, subY, subZ, cellSize / 2, subCubePointer,
+                    color);
+        } else {
+            cell1[cellPointer] = CELL_STATE_SOLID;
+            cell2[cellPointer] = color.toInt();
+            cell3[cellPointer] = CELL_STATE_UNUSED;
+            // System.out.println("Cell written!");
+        }
+    }
+
+    /**
+     * Sets the color value for the cell at the given pointer.
+     *
+     * @param pointer the cell pointer
+     * @param color   the color value to set
+     */
+    public void setCellColor(final int pointer, final int color) {
+        cell2[pointer] = color;
+    }
+
+    /**
+     * Sets the illumination value for the cell at the given pointer.
+     *
+     * @param pointer      the cell pointer
+     * @param illumination the illumination value to set
+     */
+    public void setCellIllumination(final int pointer, final int illumination) {
+        cell3[pointer] = illumination;
+    }
+
+    /**
+     * Traces a ray through the octree to find an intersecting solid cell.
+     *
+     * @param cellX    the X coordinate of the current cell center
+     * @param cellY    the Y coordinate of the current cell center
+     * @param cellZ    the Z coordinate of the current cell center
+     * @param cellSize the size of the current cell
+     * @param pointer  the pointer to the current cell
+     * @param ray      the ray to trace
+     * @return pointer to intersecting cell or TRACE_NO_HIT if no intersection
+     */
+    public int traceCell(final int cellX, final int cellY, final int cellZ,
+                         final int cellSize, final int pointer, final Ray ray) {
+        if (isCellSolid(pointer)) {
+            // solid cell
+            if (doesIntersect(cellX, cellY, cellZ, cellSize, ray) != 0) {
+                ray.hitCellSize = cellSize;
+                ray.hitCellX = cellX;
+                ray.hitCellY = cellY;
+                ray.hitCellZ = cellZ;
+                return pointer;
+            }
+            return TRACE_NO_HIT;
+        } else // cluster
+            if (doesIntersect(cellX, cellY, cellZ, cellSize, ray) != 0) {
+                final int halfOfCellSize = cellSize / 2;
+                int rayIntersectionResult;
+
+                if (ray.origin.x > cellX) {
+                    if (ray.origin.y > cellY) {
+                        if (ray.origin.z > cellZ) {
+                            // 7
+                            // 6 8 3 5 2 4 1
+
+                            if (cell7[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell7[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell6[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell6[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell8[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell8[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell3[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell3[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell2[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell2[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell4[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell4[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell5[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell5[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell1[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell1[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                        } else {
+                            // 3
+                            // 2 4 7 1 6 8 5
+                            if (cell3[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell3[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell2[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell2[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell4[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell4[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell7[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell7[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell6[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                + halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell6[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+                            if (cell8[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY + halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell8[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell1[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ - halfOfCellSize, halfOfCellSize,
+                                        cell1[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                            if (cell5[pointer] != 0) {
+                                rayIntersectionResult = traceCell(cellX
+                                                - halfOfCellSize, cellY - halfOfCellSize,
+                                        cellZ + halfOfCellSize, halfOfCellSize,
+                                        cell5[pointer], ray);
+                                if (rayIntersectionResult >= 0)
+                                    return rayIntersectionResult;
+                            }
+
+                        }
+                    } else if (ray.origin.z > cellZ) {
+                        // 6
+                        // 5 2 7 8 1 3 4
+                        if (cell6[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell6[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell7[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell7[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell2[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell2[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell5[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell5[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell8[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell8[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell3[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell3[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell1[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell1[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell4[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell4[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                    } else {
+                        // 2
+                        // 1 3 6 5 4 7 8
+                        if (cell2[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell2[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell3[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell3[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell1[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell1[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell6[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell6[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell7[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell7[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell5[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell5[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell4[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell4[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell8[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell8[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                    }
+                } else if (ray.origin.y > cellY) {
+                    if (ray.origin.z > cellZ) {
+                        // 8
+                        // 5 7 4 1 6 3 2
+
+                        if (cell8[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell8[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell7[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell7[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell5[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell5[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell4[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell4[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell3[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell3[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell1[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell1[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell6[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell6[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell2[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell2[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                    } else {
+                        // 4
+                        // 1 3 8 5 7 2 6
+
+                        if (cell4[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell4[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell8[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell8[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell3[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell3[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell1[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell1[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell7[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY + halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell7[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell5[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            - halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell5[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                        if (cell2[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            - halfOfCellSize, halfOfCellSize, cell2[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+                        if (cell6[pointer] != 0) {
+                            rayIntersectionResult = traceCell(cellX
+                                            + halfOfCellSize, cellY - halfOfCellSize, cellZ
+                                            + halfOfCellSize, halfOfCellSize, cell6[pointer],
+                                    ray);
+                            if (rayIntersectionResult >= 0)
+                                return rayIntersectionResult;
+                        }
+
+                    }
+                } else if (ray.origin.z > cellZ) {
+                    // 5
+                    // 1 6 8 4 2 7 3
+
+                    if (cell5[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell5[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell1[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell1[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell6[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell6[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell8[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell8[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell4[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell4[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell7[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell7[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell2[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell2[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell3[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell3[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                } else {
+                    // 1
+                    // 5 2 4 8 6 3 7
+
+                    if (cell1[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell1[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell5[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell5[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+                    if (cell2[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell2[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell4[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell4[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell6[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY - halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell6[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell8[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX - halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell8[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+
+                    if (cell3[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ - halfOfCellSize,
+                                halfOfCellSize, cell3[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+                    if (cell7[pointer] != 0) {
+                        rayIntersectionResult = traceCell(cellX + halfOfCellSize,
+                                cellY + halfOfCellSize, cellZ + halfOfCellSize,
+                                halfOfCellSize, cell7[pointer], ray);
+                        if (rayIntersectionResult >= 0)
+                            return rayIntersectionResult;
+                    }
+                }
+            }
+        return TRACE_NO_HIT;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/package-info.java
new file mode 100755 (executable)
index 0000000..4509e7e
--- /dev/null
@@ -0,0 +1,20 @@
+/**
+ * Octree-based voxel volume representation and rendering for the Aukio 3D engine.
+ *
+ * <p>This package provides a volumetric data structure based on an octree, which enables
+ * efficient storage and rendering of voxel data. The octree recursively subdivides 3D space
+ * into eight octants, achieving significant data compression for sparse or repetitive volumes.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume} - the main octree data structure
+ *       for storing and querying voxel cells</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.IntegerPoint} - integer 3D coordinate used
+ *       for voxel addressing</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.raytracer ray tracing through octree volumes
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.octree;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/CameraView.java
new file mode 100644 (file)
index 0000000..fa36212
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+
+import static eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RaytracingCamera.SIZE;
+
+/**
+ * Represents camera view. Used to compute direction of rays during ray tracing.
+ */
+public class CameraView {
+
+    /**
+     * Camera view coordinates.
+     */
+    Point3D cameraCenter, topLeft, topRight, bottomLeft, bottomRight;
+
+    /**
+     * Creates a camera view for ray tracing from the given camera and zoom level.
+     *
+     * @param camera the camera to create a view for
+     * @param zoom   the zoom level (scales the view frustum)
+     */
+    public CameraView(final Camera camera, final double zoom) {
+        final float viewAngle = (float) .6;
+        cameraCenter = new Point3D();
+        topLeft = new Point3D(0, 0, SIZE).rotate(-viewAngle, -viewAngle);
+        topRight = new Point3D(0, 0, SIZE).rotate(viewAngle, -viewAngle);
+        bottomLeft = new Point3D(0, 0, SIZE).rotate(-viewAngle, viewAngle);
+        bottomRight = new Point3D(0, 0, SIZE).rotate(viewAngle, viewAngle);
+
+        final Matrix3x3 m = camera.getTransform().getRotation().invert().toMatrix3x3();
+        final Point3D temp = new Point3D();
+        
+        temp.clone(topLeft);
+        m.transform(temp, topLeft);
+        
+        temp.clone(topRight);
+        m.transform(temp, topRight);
+        
+        temp.clone(bottomLeft);
+        m.transform(temp, bottomLeft);
+        
+        temp.clone(bottomRight);
+        m.transform(temp, bottomRight);
+
+        camera.getTransform().getTranslation().clone().divide(zoom).addTo(cameraCenter, topLeft, topRight, bottomLeft, bottomRight);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/LightSource.java
new file mode 100755 (executable)
index 0000000..dbfcc80
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * Represents light source.
+ */
+public class LightSource {
+
+    /**
+     * Light source color.
+     */
+    public Color color;
+    /**
+     * Light source brightness.
+     */
+    public float brightness;
+    /**
+     * Light source location.
+     */
+    Point3D location;
+
+    /**
+     * Creates a light source at the given location with the specified color and brightness.
+     *
+     * @param location   the position of the light source in world space
+     * @param color      the color of the light
+     * @param Brightness the brightness multiplier (0.0 = off, 1.0 = full)
+     */
+    public LightSource(final Point3D location, final Color color,
+                       final float Brightness) {
+        this.location = location;
+        this.color = color;
+        brightness = Brightness;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/Ray.java
new file mode 100755 (executable)
index 0000000..84d7168
--- /dev/null
@@ -0,0 +1,71 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+
+/**
+ * Represents a ray used for tracing through an {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume}.
+ *
+ * <p>A ray is defined by an {@link #origin} point and a {@link #direction} vector.
+ * After tracing through the octree, the intersection results are stored in the
+ * {@link #hitPoint}, {@link #hitCellSize}, and {@link #hitCellX}/{@link #hitCellY}/{@link #hitCellZ}
+ * fields, which are populated by the octree traversal algorithm.</p>
+ *
+ * @see RayTracer
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume#traceCell(int, int, int, int, int, Ray)
+ */
+public class Ray {
+
+    /**
+     * The origin point of the ray (the starting position in world space).
+     */
+    public Point3D origin;
+
+    /**
+     * The direction vector of the ray. Does not need to be normalized;
+     * the octree traversal handles arbitrary direction magnitudes.
+     */
+    public Point3D direction;
+
+    /**
+     * The point in world space where the ray intersected an octree cell.
+     * Set by the octree traversal algorithm after a successful intersection.
+     */
+    public Point3D hitPoint;
+
+    /**
+     * The size (side length) of the octree cell that was hit.
+     * A value of 1 indicates a leaf cell at the finest resolution.
+     */
+    public int hitCellSize;
+
+    /**
+     * The x coordinate of the octree cell that was hit.
+     */
+    public int hitCellX;
+
+    /**
+     * The y coordinate of the octree cell that was hit.
+     */
+    public int hitCellY;
+
+    /**
+     * The z coordinate of the octree cell that was hit.
+     */
+    public int hitCellZ;
+
+    /**
+     * Creates a new ray with the specified origin and direction.
+     *
+     * @param origin    the starting point of the ray
+     * @param direction the direction vector of the ray
+     */
+    public Ray(Point3D origin, Point3D direction) {
+        this.origin = origin;
+        this.direction = direction;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayHit.java
new file mode 100755 (executable)
index 0000000..b3710b7
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+/**
+ * Records the result of a ray-octree intersection test.
+ *
+ * <p>A {@code RayHit} stores the 3D world-space coordinates where a {@link Ray}
+ * intersected an octree cell, along with a pointer (index) to the intersected cell
+ * within the {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume}'s internal
+ * cell arrays.</p>
+ *
+ * @see Ray
+ * @see RayTracer
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume
+ */
+public class RayHit {
+
+    /**
+     * The x coordinate of the intersection point in world space.
+     */
+    float x;
+
+    /**
+     * The y coordinate of the intersection point in world space.
+     */
+    float y;
+
+    /**
+     * The z coordinate of the intersection point in world space.
+     */
+    float z;
+
+    /**
+     * The index (pointer) into the octree's cell arrays identifying the cell that was hit.
+     */
+    int cellPointer;
+
+    /**
+     * Creates a new ray hit record.
+     *
+     * @param x           the x coordinate of the intersection point
+     * @param y           the y coordinate of the intersection point
+     * @param z           the z coordinate of the intersection point
+     * @param cellPointer the index of the intersected cell in the octree's cell arrays
+     */
+    public RayHit(final float x, final float y, final float z,
+                  final int cellPointer) {
+        this.x = x;
+        this.y = y;
+        this.z = z;
+        this.cellPointer = cellPointer;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RayTracer.java
new file mode 100755 (executable)
index 0000000..e9a41b0
--- /dev/null
@@ -0,0 +1,411 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+import java.util.Vector;
+
+/**
+ * Ray tracing engine for rendering {@link OctreeVolume} scenes onto a {@link Texture}.
+ *
+ * <p>{@code RayTracer} implements {@link Runnable} and is designed to execute as a background
+ * task. It casts one ray per pixel through the camera's view frustum, tracing each ray
+ * into the octree volume to find intersections with solid cells. When a hit is found, the
+ * ray tracer computes per-pixel lighting by casting shadow rays from the hit point toward
+ * each {@link LightSource} along multiple surface-normal-offset directions (6 directions:
+ * +X, -X, +Y, -Y, +Z, -Z) to approximate diffuse illumination with soft shadows.</p>
+ *
+ * <p><b>Rendering pipeline</b></p>
+ * <ol>
+ *   <li>The camera's view frustum corners are obtained via {@link RaytracingCamera#getCameraView()}.</li>
+ *   <li>For each pixel, a primary ray is constructed from the camera center through the
+ *       interpolated position on the view plane.</li>
+ *   <li>The ray is traced through the octree using
+ *       {@link OctreeVolume#traceCell(int, int, int, int, int, Ray)}.</li>
+ *   <li>If a solid cell is hit, up to 6 shadow rays are cast toward each light source.
+ *       If no shadow ray is occluded, the light's contribution is accumulated.</li>
+ *   <li>The final pixel color is the cell's base color modulated by the accumulated light.</li>
+ *   <li>Computed lighting is cached in the octree cell data ({@code cell3}) for reuse.</li>
+ * </ol>
+ *
+ * <p>Progress is reported periodically by invalidating the texture's mipmap cache and
+ * requesting a repaint on the {@link ViewPanel}, allowing partial results to be displayed
+ * while rendering continues.</p>
+ *
+ * @see OctreeVolume
+ * @see Ray
+ * @see LightSource
+ * @see RaytracingCamera
+ */
+public class RayTracer implements Runnable {
+
+    /**
+     * Minimum interval in milliseconds between progress updates (texture refresh and repaint).
+     */
+    private static final int PROGRESS_UPDATE_FREQUENCY_MILLIS = 1000;
+
+    /**
+     * The raytracing camera defining the viewpoint and view frustum for ray generation.
+     */
+    private final RaytracingCamera raytracingCamera;
+
+    /**
+     * The target texture where rendered pixels are written.
+     */
+    private final Texture texture;
+
+    /**
+     * The view panel used for triggering display repaints during progressive rendering.
+     */
+    private final ViewPanel viewPanel;
+
+    /**
+     * The octree volume to be ray-traced.
+     */
+    private final OctreeVolume octreeVolume;
+
+    /**
+     * The list of light sources used for illumination calculations.
+     */
+    private final Vector<LightSource> lights;
+
+    /**
+     * Counter tracking the number of light computations performed during the current render pass.
+     */
+    private int computedLights;
+
+    /**
+     * Creates a new ray tracer for the given scene configuration.
+     *
+     * @param texture      the texture to render into; its primary bitmap dimensions
+     *                     determine the output resolution
+     * @param octreeVolume the octree volume containing the scene geometry
+     * @param lights       the light sources to use for illumination
+     * @param raytracingCamera the raytracing camera defining the viewpoint
+     * @param viewPanel    the view panel for triggering progress repaints
+     */
+    public RayTracer(final Texture texture, final OctreeVolume octreeVolume,
+                     final Vector<LightSource> lights, final RaytracingCamera raytracingCamera,
+                     final ViewPanel viewPanel) {
+
+        this.texture = texture;
+        this.octreeVolume = octreeVolume;
+        this.lights = lights;
+        this.raytracingCamera = raytracingCamera;
+        this.viewPanel = viewPanel;
+    }
+
+    /**
+     * Executes the ray tracing render pass.
+     *
+     * <p>Iterates over every pixel of the target texture, constructs a primary ray
+     * from the camera center through the view plane, traces it into the octree volume,
+     * and writes the resulting color. The texture is periodically refreshed to show
+     * progressive results.</p>
+     */
+    @Override
+    public void run() {
+        computedLights = 0;
+
+        // create camera
+
+        // Camera cam = new Camera(camCenter, upLeft, upRight, downLeft,
+        // downRight);
+
+        // add camera to the raytracing point
+        // Main.mainWorld.geometryCollection.addObject(cam);
+        // Main.mainWorld.compiledGeometry.compileGeometry(Main.mainWorld.geometryCollection);
+
+        final int width = texture.primaryBitmap.width;
+        final int height = texture.primaryBitmap.height;
+
+        final CameraView cameraView = raytracingCamera.getCameraView();
+
+        // calculate vertical vectors
+        final double x1p = cameraView.bottomLeft.x - cameraView.topLeft.x;
+        final double y1p = cameraView.bottomLeft.y - cameraView.topLeft.y;
+        final double z1p = cameraView.bottomLeft.z - cameraView.topLeft.z;
+
+        final double x2p = cameraView.bottomRight.x - cameraView.topRight.x;
+        final double y2p = cameraView.bottomRight.y - cameraView.topRight.y;
+        final double z2p = cameraView.bottomRight.z - cameraView.topRight.z;
+
+        long nextBitmapUpdate = System.currentTimeMillis()
+                + PROGRESS_UPDATE_FREQUENCY_MILLIS;
+
+        for (int y = 0; y < height; y++) {
+            final double cx1 = cameraView.topLeft.x + ((x1p * y) / height);
+            final double cy1 = cameraView.topLeft.y + ((y1p * y) / height);
+            final double cz1 = cameraView.topLeft.z + ((z1p * y) / height);
+
+            final double cx2 = cameraView.topRight.x + ((x2p * y) / height);
+            final double cy2 = cameraView.topRight.y + ((y2p * y) / height);
+            final double cz2 = cameraView.topRight.z + ((z2p * y) / height);
+
+            // calculate horizontal vector
+            final double x3p = cx2 - cx1;
+            final double y3p = cy2 - cy1;
+            final double z3p = cz2 - cz1;
+
+            for (int x = 0; x < width; x++) {
+                final double cx3 = cx1 + ((x3p * x) / width);
+                final double cy3 = cy1 + ((y3p * x) / width);
+                final double cz3 = cz1 + ((z3p * x) / width);
+
+                final Ray r = new Ray(
+                        new Point3D(cameraView.cameraCenter.x,
+                                cameraView.cameraCenter.y,
+                                cameraView.cameraCenter.z),
+                        new Point3D(
+                                cx3 - cameraView.cameraCenter.x, cy3
+                                - cameraView.cameraCenter.y, cz3
+                                - cameraView.cameraCenter.z)
+                );
+                final int c = traceRay(r);
+
+                final Color color = new Color(c);
+                texture.primaryBitmap.drawPixel(x, y, color);
+            }
+
+            if (System.currentTimeMillis() > nextBitmapUpdate) {
+                nextBitmapUpdate = System.currentTimeMillis()
+                        + PROGRESS_UPDATE_FREQUENCY_MILLIS;
+                texture.resetResampledBitmapCache();
+                viewPanel.repaintDuringNextViewUpdate();
+            }
+        }
+
+        texture.resetResampledBitmapCache();
+        viewPanel.repaintDuringNextViewUpdate();
+    }
+
+    /**
+     * Traces a single ray into the octree volume and computes the resulting pixel color.
+     *
+     * <p>If the ray intersects a solid cell, the method computes diffuse lighting by
+     * casting shadow rays from 6 surface-offset positions toward each light source.
+     * The lighting result is cached in the octree's {@code cell3} array to avoid
+     * redundant computation for the same cell.</p>
+     *
+     * @param ray the ray to trace (origin and direction must be set)
+     * @return the packed RGB color value (0xRRGGBB), or 0 if the ray hits nothing
+     */
+    private int traceRay(final Ray ray) {
+
+        final int intersectingCell = octreeVolume.traceCell(0, 0, 0,
+                octreeVolume.masterCellSize, 0, ray);
+
+        if (intersectingCell != -1) {
+            // if lighting not computed, compute it
+            if (octreeVolume.cell3[intersectingCell] == -1)
+                // if cell is larger than 1
+                if (ray.hitCellSize > 1) {
+                    // break it up
+                    octreeVolume.breakSolidCell(intersectingCell);
+                    return traceRay(ray);
+                } else {
+                    computedLights++;
+                    float red = 30, green = 30, blue = 30;
+
+                    for (final LightSource l : lights) {
+                        final double xDist = (l.location.x - ray.hitCellX);
+                        final double yDist = (l.location.y - ray.hitCellY);
+                        final double zDist = (l.location.z - ray.hitCellZ);
+
+                        double newRed = 0, newGreen = 0, newBlue = 0;
+                        double tempRed, tempGreen, tempBlue;
+
+                        double distance = Math.sqrt((xDist * xDist)
+                                + (yDist * yDist) + (zDist * zDist));
+                        distance = (distance / 3) + 1;
+
+                        final Ray r1 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX,
+                                        ray.hitCellY - (float) 1.5,
+                                        ray.hitCellZ),
+
+                                new Point3D((float) l.location.x - (float) ray.hitCellX, l.location.y
+                                        - (ray.hitCellY - (float) 1.5), (float) l.location.z
+                                        - (float) ray.hitCellZ)
+                        );
+
+                        final int rt1 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r1);
+
+                        if (rt1 == -1) {
+                            newRed = (l.color.r * l.brightness) / distance;
+                            newGreen = (l.color.g * l.brightness) / distance;
+                            newBlue = (l.color.b * l.brightness) / distance;
+                        }
+
+                        final Ray r2 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX - (float) 1.5,
+                                        ray.hitCellY, ray.hitCellZ),
+
+                                new Point3D(
+                                        l.location.x - (ray.hitCellX - (float) 1.5), (float) l.location.y
+                                        - (float) ray.hitCellY, (float) l.location.z
+                                        - (float) ray.hitCellZ)
+                        );
+
+                        final int rt2 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r2);
+
+                        if (rt2 == -1) {
+                            tempRed = (l.color.r * l.brightness) / distance;
+                            tempGreen = (l.color.g * l.brightness) / distance;
+                            tempBlue = (l.color.b * l.brightness) / distance;
+
+                            if (tempRed > newRed)
+                                newRed = tempRed;
+                            if (tempGreen > newGreen)
+                                newGreen = tempGreen;
+                            if (tempBlue > newBlue)
+                                newBlue = tempBlue;
+                        }
+
+                        final Ray r3 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX, ray.hitCellY,
+                                        ray.hitCellZ - (float) 1.5),
+                                new Point3D(
+                                        (float) l.location.x - (float) ray.hitCellX, (float) l.location.y
+                                        - (float) ray.hitCellY, l.location.z
+                                        - (ray.hitCellZ - (float) 1.5))
+                        );
+
+                        final int rt3 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r3);
+
+                        if (rt3 == -1) {
+                            tempRed = (l.color.r * l.brightness) / distance;
+                            tempGreen = (l.color.g * l.brightness) / distance;
+                            tempBlue = (l.color.b * l.brightness) / distance;
+                            if (tempRed > newRed)
+                                newRed = tempRed;
+                            if (tempGreen > newGreen)
+                                newGreen = tempGreen;
+                            if (tempBlue > newBlue)
+                                newBlue = tempBlue;
+                        }
+
+                        final Ray r4 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX,
+                                        ray.hitCellY + (float) 1.5,
+                                        ray.hitCellZ),
+
+                                new Point3D(
+                                        (float) l.location.x - (float) ray.hitCellX, l.location.y
+                                        - (ray.hitCellY + (float) 1.5), (float) l.location.z
+                                        - (float) ray.hitCellZ)
+                        );
+
+                        final int rt4 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r4);
+
+                        if (rt4 == -1) {
+                            tempRed = (l.color.r * l.brightness) / distance;
+                            tempGreen = (l.color.g * l.brightness) / distance;
+                            tempBlue = (l.color.b * l.brightness) / distance;
+                            if (tempRed > newRed)
+                                newRed = tempRed;
+                            if (tempGreen > newGreen)
+                                newGreen = tempGreen;
+                            if (tempBlue > newBlue)
+                                newBlue = tempBlue;
+                        }
+
+                        final Ray r5 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX + (float) 1.5,
+                                        ray.hitCellY, ray.hitCellZ),
+
+                                new Point3D(
+                                        l.location.x - (ray.hitCellX + (float) 1.5), (float) l.location.y
+                                        - (float) ray.hitCellY, (float) l.location.z
+                                        - (float) ray.hitCellZ)
+                        );
+
+                        final int rt5 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r5);
+
+                        if (rt5 == -1) {
+                            tempRed = (l.color.r * l.brightness) / distance;
+                            tempGreen = (l.color.g * l.brightness) / distance;
+                            tempBlue = (l.color.b * l.brightness) / distance;
+                            if (tempRed > newRed)
+                                newRed = tempRed;
+                            if (tempGreen > newGreen)
+                                newGreen = tempGreen;
+                            if (tempBlue > newBlue)
+                                newBlue = tempBlue;
+                        }
+
+                        final Ray r6 = new Ray(
+                                new Point3D(
+                                        ray.hitCellX, ray.hitCellY,
+                                        ray.hitCellZ + (float) 1.5),
+
+                                new Point3D(
+
+                                        (float) l.location.x - (float) ray.hitCellX, (float) l.location.y
+                                        - (float) ray.hitCellY, l.location.z
+                                        - (ray.hitCellZ + (float) 1.5)));
+
+                        final int rt6 = octreeVolume.traceCell(0, 0, 0,
+                                octreeVolume.masterCellSize, 0, r6);
+
+                        if (rt6 == -1) {
+                            tempRed = (l.color.r * l.brightness) / distance;
+                            tempGreen = (l.color.g * l.brightness) / distance;
+                            tempBlue = (l.color.b * l.brightness) / distance;
+                            if (tempRed > newRed)
+                                newRed = tempRed;
+                            if (tempGreen > newGreen)
+                                newGreen = tempGreen;
+                            if (tempBlue > newBlue)
+                                newBlue = tempBlue;
+                        }
+                        red += newRed;
+                        green += newGreen;
+                        blue += newBlue;
+
+                    }
+
+                    final int cellColor = octreeVolume.cell2[intersectingCell];
+
+                    red = (red * ((cellColor & 0xFF0000) >> 16)) / 255;
+                    green = (green * ((cellColor & 0xFF00) >> 8)) / 255;
+                    blue = (blue * (cellColor & 0xFF)) / 255;
+
+                    if (red > 255)
+                        red = 255;
+                    if (green > 255)
+                        green = 255;
+                    if (blue > 255)
+                        blue = 255;
+
+                    octreeVolume.cell3[intersectingCell] = (((int) red) << 16)
+                            + (((int) green) << 8) + ((int) blue);
+
+                }
+            if (octreeVolume.cell3[intersectingCell] == 0)
+                return octreeVolume.cell2[intersectingCell];
+            return octreeVolume.cell3[intersectingCell];
+        }
+
+        // return (200 << 16) + (200 << 8) + 255;
+        return 0;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/RaytracingCamera.java
new file mode 100755 (executable)
index 0000000..ed67441
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.TexturedRectangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.IOException;
+import java.net.URL;
+
+/**
+ * Raytracing camera that renders a scene to a texture.
+ * It is represented on the scene as a textured rectangle showing the raytraced view.
+ */
+public class RaytracingCamera extends TexturedRectangle {
+
+    /** Size of the camera view in world units. */
+    public static final int SIZE = 100;
+    /** Size of the rendered image in pixels. */
+    public static final int IMAGE_SIZE = 500;
+    private final CameraView cameraView;
+
+    /**
+     * Creates a raytracing camera at the specified camera position.
+     *
+     * @param camera the camera to use for the view
+     * @param zoom   the zoom level
+     */
+    public RaytracingCamera(final Camera camera, final double zoom) {
+        super(new Transform(camera.getTransform().getTranslation().clone()));
+        cameraView = new CameraView(camera, zoom);
+
+        computeCameraCoordinates(camera);
+
+        addWaitNotification(getTexture());
+    }
+
+    private void addWaitNotification(final Texture texture) {
+        // add hourglass icon
+        try {
+            final BufferedImage sprite = getSprite("eu/svjatoslav/aukio/e3d/examples/hourglass.png");
+            texture.graphics.drawImage(sprite, IMAGE_SIZE / 2,
+                    (IMAGE_SIZE / 2) - 30, null);
+        } catch (final Exception ignored) {
+        }
+
+        // add "Please wait..." message
+        texture.graphics.setColor(java.awt.Color.WHITE);
+        texture.graphics.setFont(new Font("Monospaced", Font.PLAIN, 10));
+        texture.graphics.drawString("Please wait...", (IMAGE_SIZE / 2) - 20,
+                (IMAGE_SIZE / 2) + 30);
+    }
+
+    private void computeCameraCoordinates(final Camera camera) {
+        initialize(SIZE, SIZE, IMAGE_SIZE, IMAGE_SIZE, 3);
+
+        Point3D cameraCenter = new Point3D();
+
+        topLeft.setValues(cameraCenter.x, cameraCenter.y, cameraCenter.z + SIZE);
+        topRight.clone(topLeft);
+        bottomLeft.clone(topLeft);
+        bottomRight.clone(topLeft);
+
+        final float viewAngle = (float) .6;
+
+        topLeft.rotate(cameraCenter, -viewAngle, -viewAngle);
+        topRight.rotate(cameraCenter, viewAngle, -viewAngle);
+        bottomLeft.rotate(cameraCenter, -viewAngle, viewAngle);
+        bottomRight.rotate(cameraCenter, viewAngle, viewAngle);
+
+        final Matrix3x3 m = camera.getTransform().getRotation().invert().toMatrix3x3();
+        final Point3D temp = new Point3D();
+        
+        temp.clone(topLeft);
+        temp.subtract(cameraCenter);
+        m.transform(temp, topLeft);
+        topLeft.add(cameraCenter);
+        
+        temp.clone(topRight);
+        temp.subtract(cameraCenter);
+        m.transform(temp, topRight);
+        topRight.add(cameraCenter);
+        
+        temp.clone(bottomLeft);
+        temp.subtract(cameraCenter);
+        m.transform(temp, bottomLeft);
+        bottomLeft.add(cameraCenter);
+        
+        temp.clone(bottomRight);
+        temp.subtract(cameraCenter);
+        m.transform(temp, bottomRight);
+        bottomRight.add(cameraCenter);
+
+        final Color cameraColor = new Color(255, 255, 0, 255);
+        final LineAppearance appearance = new LineAppearance(2, cameraColor);
+
+        addShape(appearance.getLine(topLeft, topRight));
+        addShape(appearance.getLine(bottomLeft, bottomRight));
+        addShape(appearance.getLine(topLeft, bottomLeft));
+        addShape(appearance.getLine(topRight, bottomRight));
+
+    }
+
+    /**
+     * Returns the camera view used for ray tracing.
+     *
+     * @return the camera view
+     */
+    public CameraView getCameraView() {
+        return cameraView;
+    }
+
+    /**
+     * Loads a sprite image from the classpath.
+     *
+     * @param ref the resource path
+     * @return the loaded image
+     * @throws IOException if the image cannot be loaded
+     */
+    public BufferedImage getSprite(final String ref) throws IOException {
+        final URL url = this.getClass().getClassLoader().getResource(ref);
+        return ImageIO.read(url);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/octree/raytracer/package-info.java
new file mode 100755 (executable)
index 0000000..e49132c
--- /dev/null
@@ -0,0 +1,21 @@
+/**
+ * Ray tracer for rendering voxel data stored in an octree structure.
+ *
+ * <p>This package implements a ray tracing renderer that casts rays through an
+ * {@link eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume} to produce rendered images
+ * of volumetric data. The ray tracer traverses the octree hierarchy for efficient
+ * intersection testing, skipping empty regions of space.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RayTracer} - main ray tracing engine</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.RaytracingCamera} - camera configuration for ray generation</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.Ray} - represents a single ray cast through the volume</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.octree.raytracer.LightSource} - defines a light source for shading</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.octree.OctreeVolume the voxel data structure
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.octree.raytracer;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/package-info.java
new file mode 100755 (executable)
index 0000000..2c18a77
--- /dev/null
@@ -0,0 +1,11 @@
+/**
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ * <p>
+ *
+ * Various 3D renderers utilizing different rendering approaches.
+ *
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Color.java
new file mode 100644 (file)
index 0000000..93e80ec
--- /dev/null
@@ -0,0 +1,353 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+/**
+ * RGBA color representation for the Aukio 3D engine.
+ *
+ * <p>This is the engine's own color class (not {@link java.awt.Color}). All color values
+ * use integer components in the range 0-255. The class provides predefined constants
+ * for common colors and several constructors for creating colors from different formats.</p>
+ *
+ * <p><b>Mutability:</b> Color fields are mutable to enable reuse during rendering
+ * (e.g., lighting calculations). This avoids allocating new Color instances per polygon.</p>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Use predefined color constants
+ * Color red = Color.RED;
+ * Color semiTransparent = Color.hex("FF000080");
+ *
+ * // Create from hex string (recommended)
+ * Color hex6 = Color.hex("FF8800");     // RGB, fully opaque
+ * Color hex8 = Color.hex("FF880080");   // RGBA with alpha
+ * Color hex3 = Color.hex("F80");        // Short RGB format
+ *
+ * // Create from integer RGBA components (0-255)
+ * Color custom = new Color(100, 200, 50, 255);
+ *
+ * // Create from packed RGB integer
+ * Color packed = new Color(0xFF8800);
+ *
+ * // Modify existing color (avoids allocation)
+ * color.set(255, 128, 0, 255);
+ * }</pre>
+ *
+ * <p><b>Important:</b> Always use this class instead of {@link java.awt.Color} when
+ * working with the Aukio 3D engine's rendering pipeline.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line
+ */
+public final class Color {
+
+    /**
+     * Fully opaque red (255, 0, 0).
+     */
+    public static final Color RED = new Color(255, 0, 0, 255);
+    /**
+     * Fully opaque green (0, 255, 0).
+     */
+    public static final Color GREEN = new Color(0, 255, 0, 255);
+    /**
+     * Fully opaque blue (0, 0, 255).
+     */
+    public static final Color BLUE = new Color(0, 0, 255, 255);
+    /**
+     * Fully opaque yellow (255, 255, 0).
+     */
+    public static final Color YELLOW = new Color(255, 255, 0, 255);
+    /**
+     * Fully opaque cyan (0, 255, 255).
+     */
+    public static final Color CYAN = new Color(0, 255, 255, 255);
+    /**
+     * Fully opaque magenta/purple (255, 0, 255).
+     */
+    public static final Color MAGENTA = new Color(255, 0, 255, 255);
+    /**
+     * Fully opaque white (255, 255, 255).
+     */
+    public static final Color WHITE = new Color(255, 255, 255, 255);
+    /**
+     * Fully opaque black (0, 0, 0).
+     */
+    public static final Color BLACK = new Color(0, 0, 0, 255);
+    /**
+     * Fully opaque purple/magenta (255, 0, 255).
+     */
+    public static final Color PURPLE = new Color(255, 0, 255, 255);
+    /**
+     * Fully transparent (alpha = 0).
+     */
+    public static final Color TRANSPARENT = new Color(0, 0, 0, 0);
+    /**
+     * Red component. 0-255.
+     */
+    public int r;
+    /**
+     * Green component. 0-255.
+     */
+    public int g;
+    /**
+     * Blue component. 0-255.
+     */
+    public int b;
+    /**
+     * Alpha component.
+     * 0 - transparent.
+     * 255 - opaque.
+     */
+    public int a;
+    private java.awt.Color cachedAwtColor;
+
+    /**
+     * Creates a black, fully opaque color (0, 0, 0, 255).
+     */
+    public Color() {
+        this.r = 0;
+        this.g = 0;
+        this.b = 0;
+        this.a = 255;
+    }
+
+    /**
+     * Creates a copy of the given color.
+     *
+     * @param parentColor the color to copy
+     */
+    public Color(final Color parentColor) {
+        r = parentColor.r;
+        g = parentColor.g;
+        b = parentColor.b;
+        a = parentColor.a;
+    }
+
+    /**
+     * Creates a color from floating-point RGBA components in the range 0.0 to 1.0.
+     * Values are internally converted to 0-255 integer range and clamped.
+     *
+     * @param r red component (0.0 = none, 1.0 = full)
+     * @param g green component (0.0 = none, 1.0 = full)
+     * @param b blue component (0.0 = none, 1.0 = full)
+     * @param a alpha component (0.0 = transparent, 1.0 = opaque)
+     */
+    public Color(final double r, final double g, final double b, final double a) {
+        this.r = clamp((int) (r * 255d));
+        this.g = clamp((int) (g * 255d));
+        this.b = clamp((int) (b * 255d));
+        this.a = clamp((int) (a * 255d));
+    }
+
+    /**
+     * Creates a color from a hexadecimal string.
+     *
+     * @param colorHexCode color code in hex format.
+     *                     Supported formats are:
+     *                     <pre>
+     *                                         RGB
+     *                                         RGBA
+     *                                         RRGGBB
+     *                                         RRGGBBAA
+     *                                         </pre>
+     */
+    public Color(String colorHexCode) {
+        switch (colorHexCode.length()) {
+            case 3:
+                r = parseHexSegment(colorHexCode, 0, 1) * 16;
+                g = parseHexSegment(colorHexCode, 1, 1) * 16;
+                b = parseHexSegment(colorHexCode, 2, 1) * 16;
+                a = 255;
+                return;
+
+            case 4:
+                r = parseHexSegment(colorHexCode, 0, 1) * 16;
+                g = parseHexSegment(colorHexCode, 1, 1) * 16;
+                b = parseHexSegment(colorHexCode, 2, 1) * 16;
+                a = parseHexSegment(colorHexCode, 3, 1) * 16;
+                return;
+
+            case 6:
+                r = parseHexSegment(colorHexCode, 0, 2);
+                g = parseHexSegment(colorHexCode, 2, 2);
+                b = parseHexSegment(colorHexCode, 4, 2);
+                a = 255;
+                return;
+
+            case 8:
+                r = parseHexSegment(colorHexCode, 0, 2);
+                g = parseHexSegment(colorHexCode, 2, 2);
+                b = parseHexSegment(colorHexCode, 4, 2);
+                a = parseHexSegment(colorHexCode, 6, 2);
+                return;
+            default:
+                throw new IllegalArgumentException("Unsupported color code: " + colorHexCode);
+        }
+    }
+
+    /**
+     * Creates a fully opaque color from a packed RGB integer.
+     *
+     * <p>The integer is interpreted as {@code 0xRRGGBB}, where the upper 8 bits
+     * are the red channel, the middle 8 bits are green, and the lower 8 bits are blue.</p>
+     *
+     * @param rgb packed RGB value (e.g. {@code 0xFF8800} for orange)
+     */
+    public Color(final int rgb) {
+        r = (rgb & 0xFF0000) >> 16;
+        g = (rgb & 0xFF00) >> 8;
+        b = rgb & 0xFF;
+        a = 255;
+    }
+
+    /**
+     * Creates a fully opaque color from RGB integer components (0-255).
+     *
+     * @param r red component (0-255)
+     * @param g green component (0-255)
+     * @param b blue component (0-255)
+     */
+    public Color(final int r, final int g, final int b) {
+        this(r, g, b, 255);
+    }
+
+    /**
+     * Creates a color from RGBA integer components (0-255).
+     * Values outside 0-255 are clamped.
+     *
+     * @param r red component (0-255)
+     * @param g green component (0-255)
+     * @param b blue component (0-255)
+     * @param a alpha component (0 = transparent, 255 = opaque)
+     */
+    public Color(final int r, final int g, final int b, final int a) {
+        this.r = clamp(r);
+        this.g = clamp(g);
+        this.b = clamp(b);
+        this.a = clamp(a);
+    }
+
+    /**
+     * Creates a color from a hexadecimal string.
+     *
+     * <p>Supported formats:</p>
+     * <ul>
+     *   <li>{@code RGB} - 3 hex digits, fully opaque</li>
+     *   <li>{@code RGBA} - 4 hex digits</li>
+     *   <li>{@code RRGGBB} - 6 hex digits, fully opaque</li>
+     *   <li>{@code RRGGBBAA} - 8 hex digits</li>
+     * </ul>
+     *
+     * @param hex hex color code
+     * @return a new Color instance
+     */
+    public static Color hex(final String hex) {
+        return new Color(hex);
+    }
+
+    /**
+     * Clamps a value to the valid color component range (0-255).
+     *
+     * @param value the value to clamp
+     * @return the clamped value
+     */
+    public static int clamp(final int value) {
+        if (value < 0) return 0;
+        if (value > 255) return 255;
+        return value;
+    }
+
+    private int parseHexSegment(String hexString, int start, int length) {
+        return Integer.parseInt(hexString.substring(start, start + length), 16);
+    }
+
+    /**
+     * Returns {@code true} if this color is fully transparent (alpha = 0).
+     *
+     * @return {@code true} if the alpha component is zero
+     */
+    public boolean isTransparent() {
+        return a == 0;
+    }
+
+    /**
+     * Sets all color components at once.
+     *
+     * <p>Values outside 0-255 are clamped. This method invalidates any cached
+     * AWT color, so the next call to {@link #toAwtColor()} will create a new one.</p>
+     *
+     * @param r red component (0-255)
+     * @param g green component (0-255)
+     * @param b blue component (0-255)
+     * @param a alpha component (0-255)
+     * @return this Color for chaining
+     */
+    public Color set(final int r, final int g, final int b, final int a) {
+        this.r = clamp(r);
+        this.g = clamp(g);
+        this.b = clamp(b);
+        this.a = clamp(a);
+        cachedAwtColor = null;
+        return this;
+    }
+
+    /**
+     * Copies values from another color.
+     *
+     * @param other the color to copy from
+     * @return this Color for chaining
+     */
+    public Color set(final Color other) {
+        this.r = other.r;
+        this.g = other.g;
+        this.b = other.b;
+        this.a = other.a;
+        cachedAwtColor = null;
+        return this;
+    }
+
+    /**
+     * Converts this color to a {@link java.awt.Color} instance for use with
+     * Java AWT/Swing graphics APIs.
+     *
+     * @return the equivalent {@link java.awt.Color}
+     */
+    public java.awt.Color toAwtColor() {
+        if (cachedAwtColor == null)
+            cachedAwtColor = new java.awt.Color(r, g, b, a);
+        return cachedAwtColor;
+    }
+
+    /**
+     * Converts this color to a packed ARGB integer as used by {@link java.awt.Color#getRGB()}.
+     *
+     * @return packed ARGB integer representation
+     */
+    public int toInt() {
+        return (a << 24) | (r << 16) | (g << 8) | b;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        Color color = (Color) o;
+
+        if (r != color.r) return false;
+        if (g != color.g) return false;
+        if (b != color.b) return false;
+        return a == color.a;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = r;
+        result = 31 * result + g;
+        result = 31 * result + b;
+        result = 31 * result + a;
+        return result;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformCoordinator.java
new file mode 100644 (file)
index 0000000..2a48f08
--- /dev/null
@@ -0,0 +1,204 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import java.util.Queue;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Coordinates the non-blocking parallel transform fork for one frame.
+ *
+ * <p>Every composite whose render list exceeds the parallel threshold (at
+ * ANY nesting level, not just the root) splits its children into chunks and
+ * submits chunk tasks here instead of transforming them serially. Chunk
+ * tasks never block on other tasks, so a fixed-size pool cannot starve;
+ * only the orchestrating render thread waits, in {@link #drainAndMergeInto}.
+ * This is what makes recursive decomposition safe: a nested heavy composite
+ * reached inside a chunk task forks its own children into the same queue
+ * and returns immediately.</p>
+ *
+ * <p>Merge order is irrelevant: the depth sort that follows the transform
+ * phase is deterministic on (Z, shapeId).</p>
+ *
+ * <p>Lifecycle: created by {@code ShapeCollection.transformShapes()} when a
+ * transform executor is available, published on the rendering context for
+ * the duration of the root transform, drained and discarded before the
+ * sort phase.</p>
+ */
+public final class ParallelTransformCoordinator {
+
+    private final ExecutorService executor;
+    private final Queue<Future<RenderAggregator>> futures = new ConcurrentLinkedQueue<>();
+    private final AtomicInteger submittedTaskCount = new AtomicInteger();
+
+    /**
+     * Shared pools of chunk-task scratch objects. Coordinators are
+     * created per render pass, so the pools are static — otherwise
+     * reuse would never survive the next pass. A pooled aggregator's
+     * queue list keeps its capacity (reset() does not shrink), and a
+     * pooled TransformStack keeps its fixed arrays, so steady-state
+     * frames allocate neither the 9.6 KB stack per chunk nor the
+     * doubling-copy chain of every chunk's queue.
+     */
+    private static final Queue<eu.svjatoslav.aukio.e3d.math.TransformStack>
+            STACK_POOL = new ConcurrentLinkedQueue<>();
+    private static final Queue<RenderAggregator>
+            AGGREGATOR_POOL = new ConcurrentLinkedQueue<>();
+
+    /**
+     * Borrows a pooled transform stack preloaded with {@code source}'s
+     * composed state. Must be returned with {@link #returnStack} when
+     * the chunk task finishes.
+     *
+     * @param source stack to copy into the borrowed instance
+     * @return a stack, possibly reused from a previous frame
+     */
+    public eu.svjatoslav.aukio.e3d.math.TransformStack borrowStack(
+            final eu.svjatoslav.aukio.e3d.math.TransformStack source) {
+        eu.svjatoslav.aukio.e3d.math.TransformStack stack = STACK_POOL.poll();
+        if (stack == null)
+            stack = new eu.svjatoslav.aukio.e3d.math.TransformStack();
+        stack.loadFrom(source);
+        return stack;
+    }
+
+    /**
+     * Returns a borrowed stack to the pool. The caller must not touch
+     * it afterwards.
+     *
+     * @param stack the borrowed stack
+     */
+    public void returnStack(
+            final eu.svjatoslav.aukio.e3d.math.TransformStack stack) {
+        STACK_POOL.offer(stack);
+    }
+
+    /**
+     * Borrows a pooled chunk aggregator (reset, but with its queue
+     * capacity intact from previous frames).
+     *
+     * @return an aggregator, possibly reused from a previous frame
+     */
+    public RenderAggregator borrowAggregator() {
+        final RenderAggregator aggregator = AGGREGATOR_POOL.poll();
+        return aggregator != null ? aggregator : new RenderAggregator();
+    }
+
+    /**
+     * Frame parity captured at construction, stamped onto recorded
+     * timeline intervals so overlapping frames keep distinct colors.
+     */
+    private final int traceParity = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.frameParity();
+
+    /**
+     * Frame-wide cap on chunk tasks. Deep hierarchies of heavy composites
+     * would otherwise fork geometrically (each fork targets cores*4 tasks);
+     * past the cap, composites transform serially inline. The cap is
+     * generous enough that realistic scenes never hit it.
+     */
+    private final int maxTasks = Runtime.getRuntime().availableProcessors() * 64;
+
+    public ParallelTransformCoordinator(final ExecutorService executor) {
+        this.executor = executor;
+    }
+
+    /**
+     * Reserves budget for {@code count} chunk tasks. Returns false when the
+     * frame-wide cap would be exceeded; the caller must then transform
+     * serially instead of forking.
+     *
+     * @param count number of chunk tasks the caller intends to submit
+     * @return true when the reservation was granted
+     */
+    public boolean tryReserveTasks(final int count) {
+        if (submittedTaskCount.addAndGet(count) > maxTasks) {
+            submittedTaskCount.addAndGet(-count);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Submits one chunk task. May be called from the orchestrating thread
+     * (root fork) or from inside a running chunk task (nested fork).
+     * Callers must have reserved budget via {@link #tryReserveTasks(int)}.
+     *
+     * @param task transforms a chunk of children into a private aggregator
+     */
+    public void submit(final Callable<RenderAggregator> task) {
+        if (eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled()) {
+            final int parity = traceParity;
+            futures.add(executor.submit(() -> {
+                final long t0 = System.nanoTime();
+                try {
+                    return task.call();
+                } finally {
+                    eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record(
+                            eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_TRANSFORM + parity,
+                            t0, System.nanoTime());
+                }
+            }));
+            return;
+        }
+        futures.add(executor.submit(task));
+    }
+
+    /**
+     * Total chunk tasks submitted to this coordinator, across all nesting
+     * levels. Diagnostics for tests and profiling.
+     *
+     * @return number of submitted chunk tasks
+     */
+    public int getSubmittedTaskCount() {
+        return submittedTaskCount.get();
+    }
+
+    /**
+     * Waits for all submitted chunk tasks, including tasks submitted by
+     * other tasks (nested forks), and merges their aggregators into
+     * {@code target}. Must be called from the single orchestrating render
+     * thread after the root composite's {@code transform()} returns.
+     *
+     * <p>Termination is guaranteed: a task enqueues all its own submissions
+     * before completing, so once every future polled so far has completed
+     * and the queue is empty, no further submissions can arrive.</p>
+     *
+     * @param target the root aggregator to merge results into
+     */
+    public void drainAndMergeInto(final RenderAggregator target) {
+        final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled();
+        final java.util.List<RenderAggregator> parts = new java.util.ArrayList<>();
+        Future<RenderAggregator> future;
+        while ((future = futures.poll()) != null) {
+            try {
+                final long t0 = trace ? System.nanoTime() : 0;
+                parts.add(future.get());
+                if (trace) {
+                    eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record(
+                            eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_AWAIT,
+                            t0, System.nanoTime());
+                }
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException("Interrupted during parallel transform", e);
+            } catch (final ExecutionException e) {
+                throw new RuntimeException("Parallel transform task failed", e.getCause());
+            }
+        }
+        target.mergeAllParallel(parts, executor);
+        // Return chunk aggregators to the pool: reset() keeps their
+        // queue capacity, so next frame's chunks start at steady-state
+        // size instead of re-growing by doubling copies.
+        for (final RenderAggregator part : parts) {
+            part.reset();
+            AGGREGATOR_POOL.offer(part);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSort.java
new file mode 100644 (file)
index 0000000..0c8d150
--- /dev/null
@@ -0,0 +1,99 @@
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+/**
+ * Stable LSD radix sort over parallel (key, index) arrays, plus the
+ * Z-to-sortable-key mapping used by {@link RenderAggregator}'s fast sort
+ * path. Byte-wise LSD passes make the sort stable, so equal keys keep
+ * their original relative order; the caller resolves remaining ties
+ * explicitly (by shapeId) afterwards.
+ *
+ * <p>Keys are interpreted as unsigned 64-bit values (digit extraction is
+ * unsigned); {@link #zSortKey(double)} returns keys pre-biased so that
+ * unsigned key order equals the comparator's paint order.</p>
+ */
+final class RadixLongSort {
+
+    private RadixLongSort() {
+    }
+
+    /**
+     * Maps a camera-space Z (already depth-bias adjusted) to a 64-bit key
+     * whose unsigned order is DESCENDING in z — i.e. the painter's
+     * back-to-front order. Matches the comparator's contract:
+     *
+     * <ul>
+     *   <li>{@code z1 > z2} (double semantics) iff key1 unsigned&lt; key2;</li>
+     *   <li>{@code -0.0} is normalized to {@code +0.0} — the comparator
+     *       compares with {@code <}/{@code >}, which treats them equal,
+     *       so they must share one key;</li>
+     *   <li>NaN (never expected: queued shapes passed the near-plane
+     *       cull) maps to one canonical key, making all-NaN ties resolve
+     *       deterministically by shapeId.</li>
+     * </ul>
+     */
+    static long zSortKey(final double zIn) {
+        double z = zIn;
+        if (z == 0.0d)
+            z = 0.0d; // normalize -0.0 -> +0.0
+        long bits = Double.doubleToRawLongBits(z);
+        // IEEE 754 -> monotone signed long (negatives flip magnitude bits)
+        bits ^= (bits >> 63) & 0x7fffffffffffffffL;
+        // descending order + unsigned-digit friendliness
+        return ~bits ^ Long.MIN_VALUE;
+    }
+
+    /**
+     * Ascending-Z variant for z-buffer mode: the opaque pass paints
+     * front-to-back for early-z rejection (correctness comes from the
+     * depth test, so order is purely a performance heuristic). Ties map
+     * to equal keys exactly like {@link #zSortKey}.
+     */
+    static long zSortKeyAscending(final double zIn) {
+        double z = zIn;
+        if (z == 0.0d)
+            z = 0.0d;
+        long bits = Double.doubleToRawLongBits(z);
+        bits ^= (bits >> 63) & 0x7fffffffffffffffL;
+        return bits ^ Long.MIN_VALUE;
+    }
+
+    /**
+     * Stable LSD radix sort of pairs {@code (keys[i], idx[i])}, ascending
+     * by unsigned key interpretation. Eight 8-bit counting passes; the
+     * even pass count leaves the result back in {@code keys}/{@code idx}
+     * (no final copy). Scratch arrays must be at least n long.
+     */
+    static void sortPairs(final long[] keys, final int[] idx, final int n,
+                          final long[] keyTmp, final int[] idxTmp) {
+        long[] srcK = keys;
+        long[] dstK = keyTmp;
+        int[] srcI = idx;
+        int[] dstI = idxTmp;
+        final int[] count = new int[256];
+        for (int shift = 0; shift < 64; shift += 8) {
+            java.util.Arrays.fill(count, 0);
+            for (int i = 0; i < n; i++)
+                count[(int) ((srcK[i] >>> shift) & 0xFF)]++;
+            int sum = 0;
+            for (int d = 0; d < 256; d++) {
+                final int c = count[d];
+                count[d] = sum;
+                sum += c;
+            }
+            for (int i = 0; i < n; i++) {
+                final int d = (int) ((srcK[i] >>> shift) & 0xFF);
+                final int p = count[d]++;
+                dstK[p] = srcK[i];
+                dstI[p] = srcI[i];
+            }
+            final long[] tk = srcK;
+            srcK = dstK;
+            dstK = tk;
+            final int[] ti = srcI;
+            srcI = dstI;
+            dstI = ti;
+        }
+        // 8 passes: after the final swap the sorted pairs are back in the
+        // caller's keys/idx arrays.
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/RenderAggregator.java
new file mode 100644 (file)
index 0000000..bb28b67
--- /dev/null
@@ -0,0 +1,861 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+/**
+ * Collects transformed shapes during a render frame and paints them in depth-sorted order.
+ *
+ * <p>The {@code RenderAggregator} implements the painter's algorithm: shapes are sorted
+ * from back to front (highest Z-depth first) and then painted sequentially. This ensures
+ * that closer shapes correctly occlude those behind them.</p>
+ *
+ * <p>When two shapes have the same Z-depth, their unique {@link AbstractCoordinateShape#shapeId}
+ * is used as a tiebreaker to guarantee deterministic rendering order.</p>
+ *
+ * <p>This class is used internally by {@link ShapeCollection} during the render pipeline.
+ * You typically do not need to interact with it directly.</p>
+ *
+ * @see ShapeCollection#paintShapes(RenderingContext)
+ * @see AbstractCoordinateShape#getZ(int)
+ */
+public class RenderAggregator {
+
+    /**
+     * Creates a new render aggregator.
+     */
+    public RenderAggregator() {
+        this(0);
+    }
+
+    /**
+     * Creates an aggregator bound to a projection buffer slot. Sorting and
+     * tile binning read the shapes' screen state for this slot, so the
+     * double-buffered pipeline can fill one slot's aggregator while the
+     * other slot's aggregator is still being painted.
+     *
+     * @param slot the buffer slot (0 or 1) this aggregator serves
+     */
+    public RenderAggregator(final int slot) {
+        this.slot = slot;
+    }
+
+    /** Buffer slot whose screen state this aggregator sorts and bins by. */
+    private final int slot;
+
+    private final ArrayList<AbstractCoordinateShape> shapes = new ArrayList<>();
+    private final ShapesZIndexComparator comparator = new ShapesZIndexComparator();
+    private boolean sorted = false;
+
+    /**
+     * The sorted queue as a flat array, produced by {@link #sort()}.
+     * Paint and binning iterate this instead of the list: sorting works
+     * on the array in place, so the queue is copied exactly once.
+     * The backing array is REUSED across frames (grow-only); only the
+     * first {@link #sortedCount} entries are valid.
+     */
+    private AbstractCoordinateShape[] sortedArray;
+    /** Valid entries of {@link #sortedArray} (it is oversized by reuse). */
+    private int sortedCount;
+
+    /**
+     * Reusable flat queue storage (grow-only). {@link #sort()} fills it
+     * either from the shape list or from {@link #mergeAllParallel}.
+     */
+    private AbstractCoordinateShape[] queueArray;
+    /** Valid entries of {@link #queueArray} after a merge. */
+    private int pendingMergeCount;
+
+    /** Reusable merge-sort scratch (grow-only), see parallelMergeSort. */
+    private AbstractCoordinateShape[] sortScratch;
+
+    /** Radix-sort scratch (grow-only): keys, indices, and their swap
+     * buffers, plus the tie-run pack array. See tryRadixSort. */
+    private long[] radixKeys;
+    private long[] radixKeysTmp;
+    private long[] tiePack;
+    private int[] radixIdx;
+    private int[] radixIdxTmp;
+
+    private static long[] ensureCapacity(final long[] array, final int capacity) {
+        return (array != null && array.length >= capacity)
+                ? array : new long[capacity];
+    }
+
+    private static int[] ensureCapacity(final int[] array, final int capacity) {
+        return (array != null && array.length >= capacity)
+                ? array : new int[capacity];
+    }
+
+    /** Grow-only capacity helper: returns {@code array} or a bigger one. */
+    private static AbstractCoordinateShape[] ensureCapacity(
+            final AbstractCoordinateShape[] array, final int capacity) {
+        return array != null && array.length >= capacity
+                ? array : new AbstractCoordinateShape[capacity];
+    }
+
+    /**
+     * Sorts all queued shapes by Z-depth (back to front) and paints them.
+     *
+     * @param renderBuffer the rendering context to paint shapes into
+     */
+    public void paint(final RenderingContext renderBuffer) {
+        ensureSorted();
+        paintSorted(renderBuffer);
+    }
+
+    /**
+     * Above this many queued shapes, {@link #sort()} uses a parallel sort
+     * on the fork/join common pool instead of a single-threaded sort.
+     */
+    private static final int PARALLEL_SORT_THRESHOLD = 8192;
+
+    /**
+     * Sorts all queued shapes by Z-depth (back to front).
+     * Must be called after all shapes are queued and before paintSorted.
+     * Uses a parallel sort for large queues.
+     */
+    public void sort() {
+        sort(null);
+    }
+
+    /**
+     * Sorts the queue by (Z, shapeId), using an instrumented parallel
+     * merge sort on the given executor for large queues. Unlike
+     * {@code Arrays.parallelSort}, every subtask is recorded on the
+     * thread-activity timeline, so the sort does not appear as phantom
+     * idle time on the worker rows. Deterministic: (Z, shapeId) is a
+     * total order, so any merge schedule yields the same result.
+     *
+     * @param executor executor for parallel sorting, or null for serial
+     */
+    public void sort(final ExecutorService executor) {
+        if (!sorted) {
+            comparator.sortSlot = slot;
+            if (pendingMergeCount > 0) {
+                // Merge already produced a flat array: sort it in place,
+                // no list copy at all
+                sortedArray = queueArray;
+                sortedCount = pendingMergeCount;
+                pendingMergeCount = 0;
+            } else {
+                // toArray(target) reuses the target when it fits:
+                // zero-allocation queue copy at steady state
+                sortedCount = shapes.size();
+                sortedArray = shapes.toArray(
+                        ensureCapacity(queueArray, sortedCount));
+                queueArray = sortedArray;
+            }
+            if (executor != null && sortedCount >= PARALLEL_SORT_THRESHOLD) {
+                if (!tryRadixSort(sortedArray, sortedCount))
+                    parallelMergeSort(sortedArray, sortedCount, comparator,
+                            executor);
+            } else if (sortedCount >= PARALLEL_SORT_THRESHOLD) {
+                Arrays.parallelSort(sortedArray, 0, sortedCount,
+                        comparator);
+            } else {
+                Arrays.sort(sortedArray, 0, sortedCount, comparator);
+            }
+            sorted = true;
+        }
+    }
+
+    /**
+     * Fast sort: maps Z-depth to unsigned-ordered long keys, stable-sorts
+     * (key, queueIndex) pairs with an LSD radix sort, then fixes equal-key
+     * runs to ascending shapeId — reproducing the comparator's total order
+     * (Z descending, shapeId ascending) exactly, without a single
+     * comparator call. Sequential memory throughout: key build and the
+     * final permute stream the queue array, the radix passes stream
+     * long/int arrays.
+     *
+     * @return true when the radix path sorted the queue
+     */
+    private boolean tryRadixSort(final AbstractCoordinateShape[] array,
+                                 final int length) {
+        radixKeys = ensureCapacity(radixKeys, length);
+        radixKeysTmp = ensureCapacity(radixKeysTmp, length);
+        radixIdx = ensureCapacity(radixIdx, length);
+        radixIdxTmp = ensureCapacity(radixIdxTmp, length);
+        for (int i = 0; i < length; i++) {
+            radixKeys[i] = RadixLongSort.zSortKey(array[i].getZ(slot));
+            radixIdx[i] = i;
+        }
+        RadixLongSort.sortPairs(radixKeys, radixIdx, length,
+                radixKeysTmp, radixIdxTmp);
+        // Equal-key runs must resolve by ascending shapeId (the
+        // comparator's tie-break; queue order is NOT construction order).
+        // Runs are almost always singletons — the pack array only
+        // materializes for actual ties.
+        int runStart = 0;
+        while (runStart < length) {
+            int runEnd = runStart + 1;
+            final long key = radixKeys[runStart];
+            while (runEnd < length && radixKeys[runEnd] == key)
+                runEnd++;
+            if (runEnd - runStart > 1) {
+                final int runLength = runEnd - runStart;
+                tiePack = ensureCapacity(tiePack, runLength);
+                for (int i = 0; i < runLength; i++)
+                    tiePack[i] =
+                            ((array[radixIdx[runStart + i]].shapeId
+                                    & 0xffffffffL) << 32)
+                                    | (radixIdx[runStart + i] & 0xffffffffL);
+                java.util.Arrays.sort(tiePack, 0, runLength);
+                for (int i = 0; i < runLength; i++)
+                    radixIdx[runStart + i] = (int) tiePack[i];
+            }
+            runStart = runEnd;
+        }
+        sortScratch = ensureCapacity(sortScratch, length);
+        for (int i = 0; i < length; i++)
+            sortScratch[i] = array[radixIdx[i]];
+        System.arraycopy(sortScratch, 0, array, 0, length);
+        return true;
+    }
+
+    /**
+     * Parallel merge sort over our own executor: chunk the array,
+     * sort chunks concurrently, then merge runs pairwise in a tree —
+     * every task recorded as KIND_SORT on the activity timeline.
+     */
+    private void parallelMergeSort(final AbstractCoordinateShape[] array,
+                                   final int length,
+                                   final Comparator<AbstractCoordinateShape> cmp,
+                                   final ExecutorService executor) {
+        final int cores = Runtime.getRuntime().availableProcessors();
+        final int runCount = Math.min(length, cores * 4);
+        final int runSize = (length + runCount - 1) / runCount;
+
+        // Phase 1: sort runs concurrently
+        runSortTasks(array, length, cmp, executor, runCount, runSize);
+
+        // Phase 2: pairwise merge tree, in-place into the REUSED scratch
+        // array (previously a fresh multi-MB array per frame)
+        AbstractCoordinateShape[] from = array;
+        sortScratch = ensureCapacity(sortScratch, length);
+        final AbstractCoordinateShape[] scratch = sortScratch;
+        int width = runSize;
+        while (width < length) {
+            final int w = width;
+            final AbstractCoordinateShape[] src = from;
+            final AbstractCoordinateShape[] dst = (from == array) ? scratch : array;
+            final java.util.List<Future<?>> futures = new java.util.ArrayList<>();
+            for (int start = 0; start < length; start += 2 * w) {
+                final int left = start;
+                final int mid = Math.min(start + w, length);
+                final int right = Math.min(start + 2 * w, length);
+                futures.add(executor.submit(() -> {
+                    final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled();
+                    final long t0 = trace ? System.nanoTime() : 0;
+                    try {
+                        mergeRuns(src, dst, cmp, left, mid, right);
+                    } finally {
+                        if (trace)
+                            eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record(
+                                    eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_SORT,
+                                    t0, System.nanoTime());
+                    }
+                }));
+            }
+            awaitAll(futures, "parallel sort merge");
+            from = dst;
+            width *= 2;
+        }
+        if (from != array)
+            System.arraycopy(from, 0, array, 0, length);
+    }
+
+    /** Sorts {@code runCount} consecutive runs of the array concurrently. */
+    private void runSortTasks(final AbstractCoordinateShape[] array,
+                              final int length,
+                              final Comparator<AbstractCoordinateShape> cmp,
+                              final ExecutorService executor,
+                              final int runCount, final int runSize) {
+        final java.util.List<Future<?>> futures = new java.util.ArrayList<>(runCount);
+        for (int r = 0; r < runCount; r++) {
+            final int from = r * runSize;
+            final int to = Math.min(length, from + runSize);
+            if (from >= to)
+                break;
+            futures.add(executor.submit(() -> {
+                final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled();
+                final long t0 = trace ? System.nanoTime() : 0;
+                try {
+                    Arrays.sort(array, from, to, cmp);
+                } finally {
+                    if (trace)
+                        eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record(
+                                eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_SORT,
+                                t0, System.nanoTime());
+                }
+            }));
+        }
+        awaitAll(futures, "parallel sort runs");
+    }
+
+    /** Merges two adjacent sorted runs [left,mid) and [mid,right) into dst. */
+    private static void mergeRuns(final AbstractCoordinateShape[] src,
+                                  final AbstractCoordinateShape[] dst,
+                                  final Comparator<AbstractCoordinateShape> cmp,
+                                  final int left, final int mid, final int right) {
+        int i = left, j = mid, k = left;
+        while (i < mid && j < right)
+            dst[k++] = cmp.compare(src[i], src[j]) <= 0 ? src[i++] : src[j++];
+        while (i < mid)
+            dst[k++] = src[i++];
+        while (j < right)
+            dst[k++] = src[j++];
+    }
+
+    private static void awaitAll(final java.util.List<Future<?>> futures, final String what) {
+        try {
+            for (final Future<?> future : futures)
+                future.get();
+        } catch (final InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException("Interrupted during " + what, e);
+        } catch (final ExecutionException e) {
+            throw new RuntimeException("Task failed during " + what, e.getCause());
+        }
+    }
+
+    private void ensureSorted() {
+        sort();
+    }
+
+    /**
+     * Paints all shapes that have already been sorted.
+     * This method can be called multiple times with different segment contexts
+     * for multi-threaded rendering.
+     *
+     * <p>If {@link #binForTiles} was called and the given context's bounds
+     * exactly match one tile, only that tile's bin is iterated — shapes
+     * that cannot touch the tile are skipped entirely. Otherwise the full
+     * sorted queue is iterated (every shape clips itself to the context
+     * bounds, so the painted result is identical).</p>
+     *
+     * @param renderBuffer the rendering context to paint shapes into
+     */
+    public void paintSorted(final RenderingContext renderBuffer) {
+        // Two passes. Opaque class first, front-to-back (the queue is
+        // back-to-front, so iterate it in reverse) with depth test +
+        // write — early-z rejects hidden surfaces before texturing.
+        // Alpha class second, in queue order (back-to-front):
+        // depth-tested against the opaque result but never
+        // depth-written, so cutout foliage keeps painter-coherent
+        // overlap among itself.
+        renderBuffer.depthPass = 1;
+        paintRange(renderBuffer, true);
+        renderBuffer.depthPass = 2;
+        paintRange(renderBuffer, false);
+        renderBuffer.depthPass = 0;
+    }
+
+    /** Iterates the matching bin (or the full sorted queue), optionally
+     * in reverse. */
+    private void paintRange(final RenderingContext renderBuffer,
+                            final boolean reverse) {
+        final int bin = matchingBinIndex(renderBuffer);
+        if (bin >= 0) {
+            final int start = binStart[bin];
+            final int end = start + binCount[bin];
+            if (reverse) {
+                for (int i = end - 1; i >= start; i--)
+                    binEntries[i].paint(renderBuffer);
+            } else {
+                for (int i = start; i < end; i++)
+                    binEntries[i].paint(renderBuffer);
+            }
+            return;
+        }
+        if (sortedArray != null) {
+            if (reverse) {
+                for (int i = sortedCount - 1; i >= 0; i--)
+                    sortedArray[i].paint(renderBuffer);
+            } else {
+                for (int i = 0; i < sortedCount; i++)
+                    sortedArray[i].paint(renderBuffer);
+            }
+        } else {
+            for (int i = 0; i < shapes.size(); i++)
+                shapes.get(i).paint(renderBuffer);
+        }
+    }
+
+    /**
+     * Tile bins in CSR (compressed-sparse-row) form: one flat,
+     * grow-only entry array plus per-tile start/count. Built fresh
+     * each frame into REUSED arrays — the previous design allocated a
+     * fresh ArrayList per tile per chunk per frame (thousands of list
+     * objects plus their backing arrays at FO4 queue sizes).
+     */
+    private AbstractCoordinateShape[] binEntries;
+    private int[] binStart;
+    private int[] binCount;
+    /** Per-(chunk × tile) scratch: counts in phase 1, write offsets in phase 3. */
+    private int[] binScratch;
+    private int binTilesX;
+    private int binTilesY;
+    private int binOriginX;
+    private int binTileW;
+    private int binTileH;
+    private int binWidth;
+    private int binHeight;
+    private boolean binsActive;
+
+    /**
+     * Returns the bin matching the given context's X/Y bounds, or -1
+     * when the context does not correspond to exactly one binned tile.
+     *
+     * @param renderBuffer the rendering context to match
+     * @return the bin index, or -1 for full-queue iteration
+     */
+    private int matchingBinIndex(final RenderingContext renderBuffer) {
+        if (!binsActive)
+            return -1;
+        final int tx = matchAxis(renderBuffer.renderMinX, renderBuffer.renderMaxX,
+                binOriginX, binTileW, binTilesX, binWidth);
+        if (tx < 0)
+            return -1;
+        final int ty = matchAxis(renderBuffer.renderMinY, renderBuffer.renderMaxY,
+                0, binTileH, binTilesY, binHeight);
+        if (ty < 0)
+            return -1;
+        return ty * binTilesX + tx;
+    }
+
+    /**
+     * Matches one axis of a context against the tile grid.
+     *
+     * @param min      context minimum coordinate on this axis
+     * @param max      context maximum coordinate (exclusive) on this axis
+     * @param origin   grid origin on this axis
+     * @param tileSize tile size on this axis
+     * @param count    tile count on this axis
+     * @param total    total grid extent on this axis (last tile reaches it)
+     * @return the tile index on this axis, or -1 when no exact match
+     */
+    private static int matchAxis(final int min, final int max, final int origin,
+                                 final int tileSize, final int count, final int total) {
+        final int rel = min - origin;
+        if (rel < 0 || rel % tileSize != 0)
+            return -1;
+        final int index = rel / tileSize;
+        if (index >= count)
+            return -1;
+        final int expectedMax = (index == count - 1)
+                ? origin + total : origin + (index + 1) * tileSize;
+        return max == expectedMax ? index : -1;
+    }
+
+    /**
+     * Below this many queued shapes the bin build runs serially; the
+     * fork/join overhead would dominate.
+     */
+    private static final int PARALLEL_BIN_MIN_SHAPES = 8192;
+
+    /**
+     * Bins the sorted queue per rectangular paint tile by screen-space
+     * overlap. Must be called after {@link #sort()} and before tile
+     * painting. Built once per frame (per eye, in stereo) on the render
+     * thread; the bins are read-only during parallel painting.
+     *
+     * <p>Each bin preserves the global (Z, shapeId) sort order, so painting
+     * a bin produces exactly the same pixels as painting the full queue
+     * into that tile. Shapes are assigned with their
+     * {@link AbstractCoordinateShape#onScreenMinY} / {@code onScreenMaxY} /
+     * {@code onScreenMinX} / {@code onScreenMaxX} bounds, which include a
+     * per-shape margin for paint output extending past the vertices
+     * (thick lines, billboards, text glyphs).</p>
+     *
+     * <p>Mouse hit detection is unaffected: a hit requires the cursor to be
+     * inside the shape, so the shape always overlaps the tile containing
+     * the cursor.</p>
+     *
+     * <p>When an executor is given and the queue is large, the sorted list
+     * is scanned in per-core chunks concurrently and the per-chunk bins are
+     * concatenated in chunk order, preserving the global sort order.</p>
+     *
+     * @param tilesX   tile columns across the viewport
+     * @param tilesY   tile rows down the viewport
+     * @param originX  X origin of the tiled viewport (eye offset in stereo)
+     * @param width    tiled viewport width in pixels
+     * @param height   full render height in pixels
+     * @param executor executor for parallel binning, or null for serial
+     */
+    public void binForTiles(final int tilesX, final int tilesY,
+                            final int originX, final int width, final int height,
+                            final ExecutorService executor) {
+        ensureSorted();
+
+        binsActive = false;
+        final int tileW = width / tilesX;
+        final int tileH = height / tilesY;
+        if (tilesX < 1 || tilesY < 1 || tileW <= 0 || tileH <= 0
+                || sortedCount == 0)
+            return;
+
+        buildBins(tilesX, tilesY, originX, tileW, tileH,
+                tilesX * tilesY, executor);
+
+        binsActive = true;
+        binTilesX = tilesX;
+        binTilesY = tilesY;
+        binOriginX = originX;
+        binTileW = tileW;
+        binTileH = tileH;
+        binWidth = width;
+        binHeight = height;
+    }
+
+    /**
+     * Floor of {@code value} as an int. The {@code (int)} cast truncates
+     * toward zero; decrement when the truncated value overshoots to get
+     * floor semantics for negatives.
+     */
+    private static int floorInt(final double value) {
+        final int result = (int) value;
+        return result > value ? result - 1 : result;
+    }
+
+    /**
+     * Builds the CSR bins in three phases over grow-only reused arrays:
+     * per-chunk counting (parallel), a tiny serial prefix pass, then
+     * per-chunk fill (parallel). Per-bin order is preserved exactly as
+     * with the old per-chunk-ArrayList concatenation: chunks cut the
+     * sorted queue contiguously and each bin's slices are laid down in
+     * chunk order.
+     */
+    private void buildBins(final int tilesX, final int tilesY,
+                           final int originX, final int tileW, final int tileH,
+                           final int tileCount,
+                           final ExecutorService executor) {
+        final AbstractCoordinateShape[] queue = sortedArray;
+        final int size = sortedCount;
+        final int targetTasks = Runtime.getRuntime().availableProcessors() * 4;
+        final int chunkSize = Math.max(1024, (size + targetTasks - 1) / targetTasks);
+        final int chunkCount = (size + chunkSize - 1) / chunkSize;
+        final double invTileW = 1.0 / tileW;
+        final double invTileH = 1.0 / tileH;
+
+        if (binStart == null || binStart.length < tileCount) {
+            binStart = new int[tileCount];
+            binCount = new int[tileCount];
+        }
+        if (binScratch == null || binScratch.length < chunkCount * tileCount) {
+            binScratch = new int[chunkCount * tileCount];
+        } else {
+            java.util.Arrays.fill(binScratch, 0, chunkCount * tileCount, 0);
+        }
+
+        final boolean parallel = executor != null
+                && size >= PARALLEL_BIN_MIN_SHAPES && chunkCount > 1;
+
+        // Phase 1: count shapes per (chunk, tile)
+        binPhase(queue, tilesX, tilesY, originX, invTileW, invTileH,
+                chunkCount, chunkSize, tileCount, true, parallel, executor);
+
+        // Phase 2 (serial, tiny): per-tile prefix over chunks -> each
+        // chunk's write offset within the tile; per-tile totals ->
+        // binStart/binCount; overall capacity for the flat entries array
+        int total = 0;
+        for (int t = 0; t < tileCount; t++) {
+            int tileTotal = 0;
+            for (int c = 0; c < chunkCount; c++) {
+                final int idx = c * tileCount + t;
+                final int n = binScratch[idx];
+                binScratch[idx] = tileTotal;
+                tileTotal += n;
+            }
+            binStart[t] = total;
+            binCount[t] = tileTotal;
+            total += tileTotal;
+        }
+        binEntries = ensureCapacity(binEntries, total);
+
+        // Phase 3: fill; each chunk bumps only its own scratch row
+        binPhase(queue, tilesX, tilesY, originX, invTileW, invTileH,
+                chunkCount, chunkSize, tileCount, false, parallel, executor);
+    }
+
+    /** Runs one bin phase over all chunks, inline or on the executor. */
+    private void binPhase(final AbstractCoordinateShape[] queue,
+                          final int tilesX, final int tilesY,
+                          final int originX,
+                          final double invTileW, final double invTileH,
+                          final int chunkCount, final int chunkSize,
+                          final int tileCount,
+                          final boolean countMode,
+                          final boolean parallel,
+                          final ExecutorService executor) {
+        final int size = sortedCount;
+        final boolean trace = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.isEnabled();
+        final int traceKind = eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.KIND_BIN
+                + eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.frameParity();
+        final java.util.List<Future<?>> futures =
+                parallel ? new java.util.ArrayList<>(chunkCount) : null;
+        for (int c = 0; c < chunkCount; c++) {
+            final int chunk = c;
+            final int from = c * chunkSize;
+            final int to = Math.min(size, from + chunkSize);
+            final Runnable body = () -> {
+                final long t0 = trace ? System.nanoTime() : 0;
+                try {
+                    binRangeCsr(queue, tilesX, tilesY, originX,
+                            invTileW, invTileH, from, to,
+                            chunk * tileCount, countMode);
+                } finally {
+                    if (trace)
+                        eu.svjatoslav.aukio.e3d.gui.ThreadActivityRecorder.record(
+                                traceKind, t0, System.nanoTime());
+                }
+            };
+            if (parallel)
+                futures.add(executor.submit(body));
+            else
+                body.run();
+        }
+        if (parallel)
+            awaitAll(futures, "parallel binning");
+    }
+
+    /**
+     * Assigns shapes[from..to) to tile bins by X/Y overlap. In count
+     * mode, increments binScratch per (chunk, tile); in fill mode,
+     * writes entries at binStart[tile] + running chunk offset. Both
+     * modes iterate in queue order, preserving (Z, shapeId) within bins.
+     */
+    private void binRangeCsr(final AbstractCoordinateShape[] queue,
+                             final int tilesX, final int tilesY,
+                             final int originX,
+                             final double invTileW, final double invTileH,
+                             final int from, final int to,
+                             final int scratchBase,
+                             final boolean countMode) {
+        for (int i = from; i < to; i++) {
+            final AbstractCoordinateShape shape = queue[i];
+
+            int firstY = floorInt(shape.onScreenMinY(slot) * invTileH);
+            int lastY = floorInt(shape.onScreenMaxY(slot) * invTileH);
+            if (lastY < 0 || firstY >= tilesY)
+                continue;
+            if (firstY < 0)
+                firstY = 0;
+            if (lastY >= tilesY)
+                lastY = tilesY - 1;
+
+            int firstX = floorInt((shape.onScreenMinX(slot) - originX) * invTileW);
+            int lastX = floorInt((shape.onScreenMaxX(slot) - originX) * invTileW);
+            if (lastX < 0 || firstX >= tilesX)
+                continue;
+            if (firstX < 0)
+                firstX = 0;
+            if (lastX >= tilesX)
+                lastX = tilesX - 1;
+
+            for (int ty = firstY; ty <= lastY; ty++) {
+                final int rowBase = ty * tilesX;
+                for (int tx = firstX; tx <= lastX; tx++) {
+                    final int tile = rowBase + tx;
+                    if (countMode) {
+                        binScratch[scratchBase + tile]++;
+                    } else {
+                        binEntries[binStart[tile]
+                                + binScratch[scratchBase + tile]++] = shape;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns the number of shapes currently queued.
+     *
+     * @return the shape count
+     */
+    public int size() {
+        if (sortedArray != null)
+            return sortedCount;
+        if (pendingMergeCount > 0)
+            return pendingMergeCount;
+        return shapes.size();
+    }
+
+    /**
+     * Queues a shape for rendering. Called during the transform phase.
+     *
+     * @param shape the shape to queue
+     */
+    public void queueShapeForRendering(final AbstractCoordinateShape shape) {
+        shapes.add(shape);
+        binsActive = false;
+    }
+
+    /**
+     * Merges all shapes queued in another aggregator into this one.
+     * Used to combine the per-task queues produced by the parallel
+     * transform phase. Merge order does not affect the final render order:
+     * {@link #sort()} is deterministic on (Z, shapeId).
+     *
+     * @param other the aggregator whose queued shapes are moved into this one
+     */
+    public void mergeFrom(final RenderAggregator other) {
+        shapes.addAll(other.shapes);
+        sorted = false;
+        binsActive = false;
+    }
+
+    /**
+     * Merges many chunk aggregators into this one, producing a flat
+     * array that {@link #sort()} consumes directly. The per-chunk lists
+     * are copied into the merged array by parallel copy tasks on the
+     * given executor — the old per-chunk {@code addAll} chain
+     * (reallocating the target list serially) is gone. Merge order is
+     * irrelevant: the following sort re-establishes deterministic
+     * (Z, shapeId) order.
+     *
+     * @param parts    chunk aggregators to merge
+     * @param executor executor for the parallel copy, or null for serial
+     */
+    public void mergeAllParallel(final List<RenderAggregator> parts,
+                                 final ExecutorService executor) {
+        final int ownSize = shapes.size();
+        int total = ownSize;
+        for (final RenderAggregator part : parts)
+            total += part.shapes.size();
+
+        // Reused flat storage (grow-only); lists are copied out with
+        // plain indexed loops — no per-part toArray copies
+        queueArray = ensureCapacity(queueArray, total);
+        final AbstractCoordinateShape[] merged = queueArray;
+        final int partCount = parts.size();
+        final int[] offsets = new int[partCount + 1];
+        offsets[0] = ownSize;
+        for (int i = 0; i < partCount; i++)
+            offsets[i + 1] = offsets[i] + parts.get(i).shapes.size();
+
+        for (int j = 0; j < ownSize; j++)
+            merged[j] = shapes.get(j);
+        shapes.clear();
+
+        if (executor == null || partCount < 2 || total < 8192) {
+            for (int i = 0; i < partCount; i++) {
+                final ArrayList<AbstractCoordinateShape> partShapes = parts.get(i).shapes;
+                for (int j = 0; j < partShapes.size(); j++)
+                    merged[offsets[i] + j] = partShapes.get(j);
+            }
+        } else {
+            final int copyTasks = Math.min(partCount,
+                    Runtime.getRuntime().availableProcessors());
+            final int perTask = (partCount + copyTasks - 1) / copyTasks;
+            final List<Future<?>> futures = new ArrayList<>(copyTasks);
+            for (int t = 0; t < copyTasks; t++) {
+                final int from = t * perTask;
+                final int to = Math.min(partCount, from + perTask);
+                if (from >= to)
+                    break;
+                futures.add(executor.submit(() -> {
+                    for (int i = from; i < to; i++) {
+                        final ArrayList<AbstractCoordinateShape> partShapes =
+                                parts.get(i).shapes;
+                        final int partSize = partShapes.size();
+                        for (int j = 0; j < partSize; j++)
+                            merged[offsets[i] + j] = partShapes.get(j);
+                    }
+                }));
+            }
+            try {
+                for (final Future<?> future : futures)
+                    future.get();
+            } catch (final InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new RuntimeException("Interrupted during parallel merge", e);
+            } catch (final ExecutionException e) {
+                throw new RuntimeException("Parallel merge task failed", e.getCause());
+            }
+        }
+
+        pendingMergeCount = total;
+        sorted = false;
+        binsActive = false;
+    }
+
+    /**
+     * Returns the live list of queued shapes, in current queue order.
+     * Package-private: exposed for pipeline verification tests.
+     *
+     * @return the queued shapes
+     */
+    List<AbstractCoordinateShape> getQueuedShapes() {
+        if (sortedArray != null)
+            return java.util.Collections.unmodifiableList(
+                    Arrays.asList(sortedArray).subList(0, sortedCount));
+        if (pendingMergeCount > 0)
+            return java.util.Collections.unmodifiableList(
+                    Arrays.asList(queueArray).subList(0, pendingMergeCount));
+        return shapes;
+    }
+
+    /**
+     * Returns the number of shapes in each segment bin, or null when no
+     * binning is active. Package-private: exposed for pipeline
+     * verification tests.
+     *
+     * @return per-segment bin sizes, or null
+     */
+    int[] getBinSizes() {
+        if (!binsActive)
+            return null;
+        return Arrays.copyOf(binCount, binTilesX * binTilesY);
+    }
+
+    /**
+     * Clears all queued shapes, preparing for a new render frame. The
+     * reusable backing arrays (queue, sort scratch, bin storage) are
+     * kept, so steady-state frames do not reallocate them.
+     */
+    public void reset() {
+        shapes.clear();
+        sortedArray = null;
+        sortedCount = 0;
+        pendingMergeCount = 0;
+        sorted = false;
+        binsActive = false;
+    }
+
+    /**
+     * Comparator that sorts shapes by Z-depth in descending order (farthest first)
+     * for the painter's algorithm. Uses shape ID as a tiebreaker.
+     */
+    static class ShapesZIndexComparator implements Comparator<AbstractCoordinateShape>, Serializable {
+
+        /** Buffer slot the in-progress sort reads Z-depths from. */
+        int sortSlot;
+
+        @Override
+        public int compare(final AbstractCoordinateShape o1, final AbstractCoordinateShape o2) {
+            final double z1 = o1.getZ(sortSlot);
+            final double z2 = o2.getZ(sortSlot);
+            if (z1 < z2)
+                return 1;
+            else if (z1 > z2)
+                return -1;
+
+            return Integer.compare(o1.shapeId, o2.shapeId);
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java
new file mode 100755 (executable)
index 0000000..bf99292
--- /dev/null
@@ -0,0 +1,548 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import eu.svjatoslav.aukio.e3d.geometry.Frustum;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.CullingStatistics;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.SubShape;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Root container that holds all 3D shapes in a scene and orchestrates their rendering.
+ *
+ * <p>{@code ShapeCollection} is the top-level scene graph. You add shapes to it, and during
+ * each render frame it transforms all shapes from world space to screen space (relative to the
+ * camera), sorts them by depth, and paints them back-to-front.</p>
+ *
+ * <p><b>Architecture:</b></p>
+ * <p>The collection contains a single {@link AbstractCompositeShape} as its root container.
+ * This root composite:</p>
+ * <ul>
+ *   <li>Stores all scene shapes in its sub-shapes registry</li>
+ *   <li>Triangulates N-vertex polygons (quads, etc.) into triangles during rendering</li>
+ *   <li>Provides group-based visibility management (show/hide groups)</li>
+ *   <li>Applies camera transform (position and rotation) to all shapes</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Get the root shape collection from the view panel
+ * ShapeCollection scene = viewPanel.getRootShapeCollection();
+ *
+ * // Add shapes to the scene
+ * scene.addShape(new Line(
+ *     new Point3D(0, 0, 100),
+ *     new Point3D(100, 0, 100),
+ *     Color.RED, 2.0
+ * ));
+ *
+ * // Add shapes with group identifier for visibility control
+ * scene.addShape(debugShape, "debug");
+ * scene.hideGroup("debug");  // hide all debug shapes
+ * scene.showGroup("debug");  // show them again
+ *
+ * // Add N-vertex polygons (quads, etc.) - automatically triangulated
+ * scene.addShape(SolidPolygon.quad(p1, p2, p3, p4, color));
+ * }</pre>
+ *
+ * <p>The {@link #addShape} method is synchronized, making it safe to add shapes from
+ * any thread while the rendering loop is active.</p>
+ *
+ * @see ViewPanel#getRootShapeCollection()
+ * @see AbstractShape the base class for all shapes
+ * @see AbstractCompositeShape the root composite that stores and processes all shapes
+ * @see RenderAggregator handles depth sorting and painting
+ */
+public class ShapeCollection {
+
+    /**
+     * Render aggregators that collect transformed shapes, sort by depth, and
+     * paint — one per projection buffer slot. The double-buffered pipeline
+     * fills one slot's aggregator during transform while the other slot's
+     * aggregator is still being painted. Slot 0 serves all single-buffered
+     * (serial) rendering.
+     */
+    private final RenderAggregator[] aggregators = { new RenderAggregator(0), new RenderAggregator(1),
+            new RenderAggregator(2) };
+
+    /**
+     * The transform stack used during the rendering pipeline.
+     */
+    private final TransformStack transformStack = new TransformStack();
+
+    /**
+     * Global transform-cycle counter; each transform pass gets a unique id
+     * for per-cycle memoization (composite subtree weights).
+     */
+    private long transformCycleCounter;
+
+    // Subpixel-culling verdict cache state (only advanced when the cull
+    // is enabled on the pass context): the epoch bumps on significant
+    // camera change, and unconditionally every CULL_MAX_FRAMES frames so
+    // shape-side transform changes cannot hide behind a stale verdict.
+    private static final double CULL_TRANSLATE_DELTA = Double.parseDouble(
+            System.getProperty("aukio.cull.subpixel.translate", "25"));
+    private static final double CULL_ROTATE_DELTA = Double.parseDouble(
+            System.getProperty("aukio.cull.subpixel.rotate", "0.01"));
+    private static final int CULL_MAX_FRAMES = Integer.parseInt(
+            System.getProperty("aukio.cull.subpixel.frames", "30"));
+    private int subpixelCullingEpoch;
+    private int cullEpochAge;
+    private double cullCamX = Double.NaN;
+    private double cullCamY;
+    private double cullCamZ;
+    private double cullCamQw;
+    private double cullCamQx;
+    private double cullCamQy;
+    private double cullCamQz;
+
+
+    // Camera rotation. We reuse this object for every frame render to avoid garbage collections.
+    private final Transform cameraRotationTransform = new Transform();
+
+    // Camera rotation. We reuse this object for every frame render to avoid garbage collections.
+    private final Transform cameraTranslationTransform = new Transform();
+
+    /**
+     * Root composite shape containing all scene shapes.
+     *
+     * <p>Handles:</p>
+     * <ul>
+     *   <li>N-gon triangulation (quads → triangles)</li>
+     *   <li>Group-based visibility management</li>
+     *   <li>Camera transform application</li>
+     *   <li>LOD slicing for nested composites</li>
+     * </ul>
+     *
+     * <p>The transform is updated each frame to match the camera position and rotation.</p>
+     */
+    private final AbstractCompositeShape rootComposite;
+
+    /**
+     * Creates a new empty shape collection with a root composite.
+     */
+    public ShapeCollection() {
+        rootComposite = new AbstractCompositeShape();
+        rootComposite.setRootComposite(true);
+    }
+
+    /**
+     * Adds a shape to this collection without a group identifier. This method is thread-safe.
+     *
+     * @param shape the shape to add to the scene
+     */
+    public synchronized void addShape(final AbstractShape shape) {
+        rootComposite.addShape(shape);
+    }
+
+    /**
+     * Collects the triangles currently rendered for this collection (render
+     * lists of all composites, recursively). Used by derived structures like
+     * the global-illumination scene snapshot. Render lists build lazily
+     * during transform, so the result may be empty until the first frame.
+     *
+     * @param out list receiving the polygons
+     */
+    public void collectRenderTriangles(final List<eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape> out) {
+        rootComposite.collectRenderTriangles(out);
+    }
+
+    /**
+     * Adds a shape to this collection with a group identifier for visibility control. This method is thread-safe.
+     *
+     * <p>Grouped shapes can be shown, hidden, or removed together using
+     * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
+     *
+     * @param shape   the shape to add
+     * @param groupId the group identifier, or {@code null} for ungrouped shapes
+     */
+    public synchronized void addShape(final AbstractShape shape, final String groupId) {
+        rootComposite.addShape(shape, groupId);
+    }
+
+    /**
+     * Returns all shapes currently in this collection (including hidden ones).
+     *
+     * <p>This returns the sub-shapes from the registry, unwrapped from their {@link SubShape}
+     * containers. For access to group and visibility metadata, use {@link #getSubShapesRegistry()}.</p>
+     *
+     * @return a collection of all shapes in the scene
+     */
+    public Collection<AbstractShape> getShapes() {
+        final List<AbstractShape> result = new ArrayList<>();
+        for (final SubShape subShape : rootComposite.getSubShapesRegistry()) {
+            result.add(subShape.getShape());
+        }
+        return result;
+    }
+
+    /**
+     * Returns the sub-shapes registry with group and visibility metadata.
+     *
+     * <p>This provides direct access to the registry for advanced operations
+     * like inspecting group assignments or visibility states.</p>
+     *
+     * @return the list of sub-shapes with their metadata
+     */
+    public List<SubShape> getSubShapesRegistry() {
+        return rootComposite.getSubShapesRegistry();
+    }
+
+    /**
+     * Removes all shapes from this collection. This method is thread-safe.
+     */
+    public synchronized void clear() {
+        rootComposite.getSubShapesRegistry().clear();
+        rootComposite.setCacheNeedsRebuild(true);
+    }
+
+    /**
+     * Shows all shapes belonging to the specified group.
+     *
+     * @param groupId the group identifier to show
+     */
+    public void showGroup(final String groupId) {
+        rootComposite.showGroup(groupId);
+    }
+
+    /**
+     * Hides all shapes belonging to the specified group.
+     * Hidden shapes are not rendered but remain in the collection.
+     *
+     * @param groupId the group identifier to hide
+     */
+    public void hideGroup(final String groupId) {
+        rootComposite.hideGroup(groupId);
+    }
+
+    /**
+     * Permanently removes all shapes belonging to the specified group.
+     *
+     * @param groupId the group identifier to remove
+     */
+    public void removeGroup(final String groupId) {
+        rootComposite.removeGroup(groupId);
+    }
+
+    /**
+     * Returns all sub-shapes belonging to the specified group.
+     *
+     * @param groupId the group identifier to match
+     * @return list of matching sub-shapes
+     */
+    public List<SubShape> getGroup(final String groupId) {
+        return rootComposite.getGroup(groupId);
+    }
+
+    /**
+     * Transforms all shapes to screen space and queues them for rendering.
+     * This is phase 1 of the multi-threaded render pipeline.
+     *
+     * <p>Updates the root composite's transform to match the camera position and rotation,
+     * then delegates to the root composite's transform method which handles all shapes.</p>
+     *
+     * <p><b>Frustum culling:</b> The view frustum is computed from camera state and
+     * screen dimensions before transforming shapes. Composite shapes can test their
+     * bounding boxes against this frustum to skip invisible objects.</p>
+     *
+     * <p><b>Culling statistics:</b> Statistics are reset and total shape count computed
+     * at the start of each frame. Visible shapes are counted as they are queued.</p>
+     *
+     * @param viewPanel        the view panel providing the camera state
+     * @param renderingContext the rendering context with frame metadata
+     */
+    public synchronized void transformShapes(final ViewPanel viewPanel,
+                                             final RenderingContext renderingContext) {
+        transformShapesBegin(viewPanel, renderingContext);
+        drainTransformShapes(renderingContext);
+    }
+
+    /**
+     * Camera-based variant of {@link #transformShapes(ViewPanel, RenderingContext)}
+     * for headless rendering without a {@link ViewPanel} (off-screen snapshots,
+     * golden-image tests, GI scene setup).
+     *
+     * @param camera           the camera providing position and orientation
+     * @param renderingContext the rendering context with frame metadata
+     */
+    public synchronized void transformShapes(final Camera camera,
+                                             final RenderingContext renderingContext) {
+        transformShapesBegin(camera, renderingContext);
+        drainTransformShapes(renderingContext);
+    }
+
+    /**
+     * First half of {@link #transformShapes}: resets the frame's
+     * aggregator, computes the frustum and walks the scene tree,
+     * forking heavy composites into chunk tasks on the frame's
+     * coordinator. Returns WITHOUT waiting for the chunk tasks; the
+     * caller hands the coordinator to {@link #drainTransformShapes}
+     * later (the pipeline drains it inside the asynchronous
+     * sort/bin/paint continuation on a worker thread).
+     *
+     * @param viewPanel        the view panel providing the camera state
+     * @param renderingContext the pass rendering context
+     */
+    public synchronized void transformShapesBegin(final ViewPanel viewPanel,
+                                                  final RenderingContext renderingContext) {
+        transformShapesBegin(viewPanel.getCamera(), renderingContext);
+    }
+
+    /**
+     * Camera-based variant of {@link #transformShapesBegin(ViewPanel, RenderingContext)}
+     * for headless rendering without a {@link ViewPanel}.
+     *
+     * @param camera           the camera providing position and orientation
+     * @param renderingContext the pass rendering context
+     */
+    public synchronized void transformShapesBegin(final Camera camera,
+                                                  final RenderingContext renderingContext) {
+
+        final RenderAggregator aggregator = aggregators[renderingContext.vertexSlot];
+        aggregator.reset();
+        transformStack.clear();
+        renderingContext.transformCycleId = ++transformCycleCounter;
+
+        // Update frustum for this frame (used for frustum culling)
+        if (renderingContext.frustum == null) {
+            renderingContext.frustum = new Frustum();
+        }
+        renderingContext.frustum.update(camera, renderingContext.stereoViewportWidth, renderingContext.height);
+
+        // Initialize culling statistics for this frame
+        if (renderingContext.cullingStatistics == null) {
+            renderingContext.cullingStatistics = new CullingStatistics();
+        }
+        renderingContext.cullingStatistics.reset();
+        // Note: totalShapes will be counted during rendering as shapes are queued
+        // This ensures we count actual rendered primitives (after triangulation/slicing)
+
+        // final Transform rootTransform = rootComposite.getTransform();
+        // TODO: Investigate if this transform can be reused instead of solution below
+
+        cameraRotationTransform.getRotation().set(camera.getTransform().getRotation());
+        cameraRotationTransform.invalidateCache();
+        transformStack.addTransform(cameraRotationTransform);
+
+        final Point3D cameraLocation = camera.getTransform().getTranslation();
+        renderingContext.viewerPosition.x = cameraLocation.x;
+        renderingContext.viewerPosition.y = cameraLocation.y;
+        renderingContext.viewerPosition.z = cameraLocation.z;
+
+        // Advance the subpixel-culling verdict epoch when the camera has
+        // moved significantly since the last bump (translation in world
+        // units; rotation compared on quaternion components, 0.01 ~ 1.1
+        // degrees), or periodically. While the epoch holds, culled shapes
+        // skip their entire transform setup.
+        if (renderingContext.subpixelCullingThreshold > 0) {
+            final Quaternion camRot = camera.getTransform().getRotation();
+            final boolean moved = Double.isNaN(cullCamX)
+                    || Math.abs(cameraLocation.x - cullCamX) > CULL_TRANSLATE_DELTA
+                    || Math.abs(cameraLocation.y - cullCamY) > CULL_TRANSLATE_DELTA
+                    || Math.abs(cameraLocation.z - cullCamZ) > CULL_TRANSLATE_DELTA
+                    || Math.abs(camRot.w - cullCamQw) > CULL_ROTATE_DELTA
+                    || Math.abs(camRot.x - cullCamQx) > CULL_ROTATE_DELTA
+                    || Math.abs(camRot.y - cullCamQy) > CULL_ROTATE_DELTA
+                    || Math.abs(camRot.z - cullCamQz) > CULL_ROTATE_DELTA;
+            if (moved || ++cullEpochAge >= CULL_MAX_FRAMES) {
+                subpixelCullingEpoch++;
+                cullEpochAge = 0;
+                cullCamX = cameraLocation.x;
+                cullCamY = cameraLocation.y;
+                cullCamZ = cameraLocation.z;
+                cullCamQw = camRot.w;
+                cullCamQx = camRot.x;
+                cullCamQy = camRot.y;
+                cullCamQz = camRot.z;
+            }
+            renderingContext.subpixelCullingEpoch = subpixelCullingEpoch;
+        }
+        cameraTranslationTransform.getTranslation().x = -cameraLocation.x;
+        cameraTranslationTransform.getTranslation().y = -cameraLocation.y;
+        cameraTranslationTransform.getTranslation().z = -cameraLocation.z;
+        transformStack.addTransform(cameraTranslationTransform);
+
+        // Non-blocking parallel fork: composites with enough children (at
+        // any nesting level) submit chunk tasks to the coordinator instead
+        // of transforming serially. The orchestrating thread (this one) is
+        // the only one allowed to block on task completion.
+        if (renderingContext.transformExecutor != null) {
+            renderingContext.transformCoordinator =
+                    new ParallelTransformCoordinator(renderingContext.transformExecutor);
+        }
+        try {
+            rootComposite.transform(transformStack, aggregator, renderingContext);
+        } catch (final RuntimeException e) {
+            drainTransformShapes(renderingContext);
+            throw e;
+        }
+    }
+
+    /**
+     * Second half of {@link #transformShapes}: waits for all transform
+     * chunk tasks and merges their aggregators into the frame's root
+     * aggregator. Safe to call from a worker thread while the render
+     * thread already walks the NEXT pass: chunk tasks capture their own
+     * transform-stack snapshots, and the aggregator is addressed by the
+     * pass's projection slot, so passes never touch the same state.
+     *
+     * @param renderingContext the pass rendering context
+     */
+    public void drainTransformShapes(final RenderingContext renderingContext) {
+        if (renderingContext.transformCoordinator != null) {
+            renderingContext.transformCoordinator.drainAndMergeInto(
+                    aggregators[renderingContext.vertexSlot]);
+            renderingContext.lastTransformTaskCount =
+                    renderingContext.transformCoordinator.getSubmittedTaskCount();
+            renderingContext.transformCoordinator = null;
+        }
+    }
+
+    /**
+     * Sorts all queued shapes by Z-depth (back to front).
+     * This is phase 2 of the multi-threaded render pipeline.
+     */
+    public void sortShapes() {
+        aggregators[0].sort();
+    }
+
+    /**
+     * Sorts the given buffer slot's queued shapes by Z-depth.
+     *
+     * @param slot buffer slot to sort (0 or 1)
+     */
+    public void sortShapes(final int slot) {
+        aggregators[slot].sort();
+    }
+
+    /**
+     * Sorts the given buffer slot's queued shapes by Z-depth, parallelized
+     * over the given executor (instrumented parallel merge sort).
+     *
+     * @param slot     buffer slot to sort
+     * @param executor executor for parallel sorting
+     */
+    public void sortShapes(final int slot, final java.util.concurrent.ExecutorService executor) {
+        aggregators[slot].sort(executor);
+    }
+
+    /**
+     * Bins the sorted render queue per rectangular paint tile by
+     * screen-space overlap, so each paint thread iterates only the
+     * shapes that can touch its tile instead of the whole queue.
+     * Call after {@link #sortShapes()}, before tile painting.
+     *
+     * @param tilesX   tile columns across the viewport
+     * @param tilesY   tile rows down the viewport
+     * @param originX  X origin of the tiled viewport (eye offset in stereo)
+     * @param width    tiled viewport width in pixels
+     * @param height   full render height in pixels
+     * @param executor executor for parallel binning, or null for serial
+     */
+    public void binShapesForTiles(final int tilesX, final int tilesY,
+                                  final int originX, final int width, final int height,
+                                  final ExecutorService executor) {
+        aggregators[0].binForTiles(tilesX, tilesY, originX, width, height, executor);
+    }
+
+    /**
+     * Slot-selecting variant of
+     * {@link #binShapesForTiles(int, int, int, int, int, ExecutorService)}
+     * for the double-buffered pipeline.
+     *
+     * @param slot     buffer slot whose queue gets binned (0 or 1)
+     * @param tilesX   tile columns across the viewport
+     * @param tilesY   tile rows down the viewport
+     * @param originX  X origin of the tiled viewport (eye offset in stereo)
+     * @param width    tiled viewport width in pixels
+     * @param height   full render height in pixels
+     * @param executor executor for parallel binning, or null for serial
+     */
+    public void binShapesForTiles(final int slot, final int tilesX, final int tilesY,
+                                  final int originX, final int width, final int height,
+                                  final ExecutorService executor) {
+        aggregators[slot].binForTiles(tilesX, tilesY, originX, width, height, executor);
+    }
+
+    /**
+     * Paints all already-sorted shapes to the rendering context.
+     * This is phase 3 of the multi-threaded render pipeline.
+     * Can be called multiple times with different segment contexts.
+     *
+     * @param renderingContext the rendering context to paint into
+     */
+    public void paintShapes(final RenderingContext renderingContext) {
+        aggregators[renderingContext.vertexSlot].paintSorted(renderingContext);
+    }
+
+    /**
+     * Returns the number of shapes queued for rendering.
+     *
+     * @return the shape count
+     */
+    public int getQueuedShapeCount() {
+        return aggregators[0].size();
+    }
+
+    /**
+     * Returns the live list of shapes queued in the aggregator.
+     * Package-private: exposed for pipeline verification tests.
+     *
+     * @return the queued shapes
+     */
+    List<AbstractCoordinateShape> getQueuedShapes() {
+        return aggregators[0].getQueuedShapes();
+    }
+
+    /**
+     * Returns the number of shapes in each paint-segment bin, or null when
+     * no binning is active. Package-private: exposed for pipeline
+     * verification tests.
+     *
+     * @return per-segment bin sizes, or null
+     */
+    int[] getBinSizes() {
+        return aggregators[0].getBinSizes();
+    }
+
+    /**
+     * Returns the root composite shape containing all scene shapes.
+     *
+     * <p>Useful for headless setups that must transform the scene without
+     * going through the full collection pipeline (e.g. pre-building render
+     * lists before a GI snapshot reads them via
+     * {@link #collectRenderTriangles}).</p>
+     *
+     * @return the root composite (never null)
+     */
+    public AbstractCompositeShape getRootComposite() {
+        return rootComposite;
+    }
+
+    /**
+     * Sets the cache rebuild flag on the root composite.
+     *
+     * <p>Used internally to force a render-list rebuild. Public for advanced use cases.</p>
+     *
+     * @param needsRebuild {@code true} to force cache rebuild
+     */
+    public void setCacheNeedsRebuild(final boolean needsRebuild) {
+        rootComposite.setCacheNeedsRebuild(needsRebuild);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java
new file mode 100644 (file)
index 0000000..c9ae4ef
--- /dev/null
@@ -0,0 +1,896 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.GiLightProvider;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+import java.util.ArrayList;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Progressive CPU global illumination, running on dedicated low-priority
+ * threads (never on the render ForkJoinPool).
+ *
+ * <p><b>Two sampling resolutions:</b></p>
+ * <ul>
+ *   <li><b>Lightmapped triangles</b> ({@link LightmappedShape}, e.g. the
+ *       wrapped polygons of a lightmapping-enabled
+ *       {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}):
+ *       per-texel sampling. Shadows and gradients live INSIDE the polygon
+ *       surface; the painted texture is the premultiplied composite
+ *       (baseColor x ambient+direct+indirect), regenerated on GI threads
+ *       and swapped in double-buffered — painters never see a half-updated
+ *       texture.</li>
+ *   <li><b>Plain solid polygons</b>: per-polygon sampling; the result feeds
+ *       the flat-shading path through {@link GiLightProvider} (shadow tests
+ *       + indirect add). Polygons stay single-colored.</li>
+ * </ul>
+ *
+ * <p><b>Estimator:</b> each sample casts one cosine-weighted hemisphere ray
+ * from the surface. At the hit it evaluates direct light with cached shadow
+ * tests (next-event estimation) plus the hit surface's current indirect
+ * estimate, so bounce light propagates deeper over sweeps without an
+ * explicit recursion limit. Two nested exponential moving averages shape
+ * what the user sees: the inner per-sample EMA smooths Monte Carlo noise in
+ * the indirect term, and the outer per-composite-update EMA wraps the
+ * complete sum ambient+direct+indirect with a fixed alpha — lightmaps start
+ * at a uniform medium irradiance and glide to the traced solution (lit
+ * areas brighten, unlit areas sink to darkness), so no black-to-lit flash
+ * or shadow pop is possible. Scene or light changes rebuild the
+ * snapshot and restart convergence. When converged, workers idle at a low
+ * cadence instead of burning CPU.</p>
+ *
+ * <p><b>Render-side cost:</b> zero ray casting. Lightmapped triangles paint
+ * from their current composite texture; the flat-shading path only reads
+ * cached per-polygon values. Frame rate is unaffected by GI quality.</p>
+ *
+ * <p><b>Limitations:</b> diffuse light only; polygon vertices are used in
+ * composite-local space, so scenes combining composites with non-identity
+ * transforms are traced incorrectly.</p>
+ *
+ * <p><b>Usage:</b></p>
+ * <pre>{@code
+ * GlobalIllumination gi = viewPanel.enableGlobalIllumination(); // 2 threads
+ * }</pre>
+ */
+public class GlobalIllumination implements GiLightProvider {
+
+    /**
+     * Diffuse bounce gain: outgoing radiance is albedo/pi times irradiance
+     * (the pi comes from cosine-weighted hemisphere integration). Without it
+     * the indirect feedback loop converges to several times the direct
+     * energy and the scene saturates.
+     */
+    private static final double BOUNCE_GAIN = 1.0 / Math.PI;
+
+    /** Origin offset along the surface normal to avoid self-intersection. */
+    private static final double ORIGIN_EPSILON = 0.5;
+
+    /** Shadow ray segments end this far before the light to avoid grazing hits. */
+    private static final double LIGHT_EPSILON = 1.0;
+
+    /** Minimum sleep between sweeps once the solution has converged. */
+    private static final long IDLE_SLEEP_MS = 250;
+
+    /** Maximum converged-state sleep: edits restart tracing at this latency. */
+    private static final long IDLE_SLEEP_MAX_MS = 2000;
+
+    /** Minimum interval between composite texture updates. */
+    private static final long COMPOSITE_INTERVAL_MS = 500;
+
+    /** Per-light visibility is tracked for at most this many lights. */
+    private static final int MAX_TRACKED_LIGHTS = 16;
+
+    /** Debug statistics with {@code -De3d.gi.debug}. */
+    private static final boolean DEBUG = Boolean.getBoolean("e3d.gi.debug");
+
+    /**
+     * EMA policy for the inner per-sample indirect blend: "fixed" (default,
+     * 0.15) keeps every ray hit equally intensive forever — fading toward
+     * darkness stays as alive as brightening. "adaptive" decays alpha with
+     * sample count (lower final noise, but late-time adaptation nearly
+     * stops). {@code -De3d.gi.alphaMode}.
+     */
+    private static final String ALPHA_MODE = System.getProperty("e3d.gi.alphaMode", "fixed");
+    /** Adaptive alpha floor: keeps post-change fade alive. */
+    private static final double ALPHA_FLOOR = Double.parseDouble(System.getProperty("e3d.gi.alphaFloor", "0.08"));
+    /** Spatial despeckle of indirect at composite time. */
+    private static final boolean DESPECKLE = Boolean.parseBoolean(System.getProperty("e3d.gi.despeckle", "true"));
+
+    /**
+     * Outer EMA: fraction of the freshly computed total irradiance blended
+     * into the on-screen composite estimate per update. Lower = slower,
+     * calmer fade (and less visible Monte Carlo noise); higher = faster
+     * reaction. At the default 0.2 and 500 ms update cadence the scene
+     * reaches its true lighting in roughly 5 seconds.
+     * {@code -De3d.gi.compositeAlpha}.
+     */
+    private static final double COMPOSITE_ALPHA =
+            Double.parseDouble(System.getProperty("e3d.gi.compositeAlpha", "0.2"));
+
+    /**
+     * Convergence: declared after five consecutive composite updates whose
+     * average per-texel estimate movement falls below this many light
+     * units. With a constant alpha the estimate never freezes completely
+     * (Monte Carlo jitter), so this judges the VISIBLE movement, not the
+     * per-sample deltas. {@code -De3d.gi.calmThreshold}.
+     */
+    private static final double CALM_THRESHOLD =
+            Double.parseDouble(System.getProperty("e3d.gi.calmThreshold", "1.0"));
+
+    private final ShapeCollection shapes;
+    private final LightingManager lightingManager;
+    private final int threadCount;
+
+    private final List<Thread> threads = new ArrayList<>();
+    private volatile boolean running;
+
+    // --- Snapshot (rebuilt on scene/light change; published via volatile) ---
+
+    private volatile Snapshot snapshot;
+
+    /** Per-polygon state for PLAIN solid polygons, read by render threads. */
+    private final ConcurrentHashMap<AbstractCoordinateShape, GiState> states = new ConcurrentHashMap<>();
+
+    private int lastSeenRenderListVersion = -1;
+    private double lastLightSignature = Double.NaN;
+    private final AtomicInteger workIndex = new AtomicInteger();
+    private volatile int calmSweeps;
+    private volatile long lastCompositeUpdate;
+    private final java.util.concurrent.atomic.AtomicBoolean compositeUpdateInFlight =
+            new java.util.concurrent.atomic.AtomicBoolean();
+
+    private static class Snapshot {
+        List<TriangleBvh.Entry> entries;
+        TriangleBvh bvh;
+        List<LightSource> lights;
+        IdentityHashMap<LightSource, Integer> lightIndex;
+        double ambientR, ambientG, ambientB;
+        /** Flattened work list: one item per lightmap texel / plain polygon. */
+        WorkItem[] workItems;
+        /** All lightmaps in the snapshot (for composite updates). */
+        List<Lightmap> lightmaps;
+    }
+
+    /** One unit of GI work: a lightmap texel, or a whole plain polygon. */
+    private static class WorkItem {
+        TriangleBvh.Entry entry;
+        int texel; // -1 = plain polygon
+    }
+
+    /** Progressive per-polygon GI state for plain solid polygons. */
+    private static class GiState {
+        volatile float indirectR, indirectG, indirectB; // irradiance, light units
+        volatile long visibleBits;   // per-light: shadow ray says visible
+        volatile long knownBits;     // per-light: visibility computed at least once
+        int nextLight;               // round-robin cursor (GI threads only)
+        int samples;                 // adaptive EMA counter (GI threads only)
+    }
+
+    /**
+     * Creates the GI system. Call {@link #start()} to begin tracing.
+     *
+     * @param shapes          the scene to trace
+     * @param lightingManager the lights to sample
+     * @param threadCount     dedicated worker threads (2 is a good default)
+     */
+    public GlobalIllumination(final ShapeCollection shapes,
+                              final LightingManager lightingManager,
+                              final int threadCount) {
+        this.shapes = shapes;
+        this.lightingManager = lightingManager;
+        this.threadCount = Math.max(1, threadCount);
+    }
+
+    /** Registers the GI provider and starts the worker threads. */
+    public void start() {
+        if (running)
+            return;
+        running = true;
+        lightingManager.setGiProvider(this);
+        for (int i = 0; i < threadCount; i++) {
+            final Thread thread = new Thread(this::workLoop, "e3d-gi-" + i);
+            thread.setDaemon(true);
+            thread.setPriority(Thread.MIN_PRIORITY);
+            threads.add(thread);
+            thread.start();
+        }
+    }
+
+    /** Stops the worker threads and unregisters the provider. */
+    public void stop() {
+        running = false;
+        lightingManager.setGiProvider(null);
+        for (final Thread thread : threads)
+            thread.interrupt();
+        threads.clear();
+    }
+
+    /**
+     * Returns whether the GI worker threads are running.
+     *
+     * @return {@code true} after {@link #start()} and before {@link #stop()}
+     */
+    public boolean isRunning() {
+        return running;
+    }
+
+    /**
+     * Returns whether the solution has converged (workers idling at a low
+     * duty cycle). Convergence is declared after five consecutive composite
+     * updates whose average per-texel estimate movement is below
+     * {@code e3d.gi.calmThreshold} (default 1.0 light unit).
+     *
+     * @return {@code true} when converged
+     */
+    public boolean isConverged() {
+        return calmSweeps >= 5;
+    }
+
+    /**
+     * Returns the number of work items in the current scene snapshot
+     * (one per lightmap texel plus one per plain polygon), or 0 when no
+     * snapshot has been built yet.
+     *
+     * @return the work item count
+     */
+    public int getWorkItemCount() {
+        final Snapshot snap = snapshot;
+        return snap == null || snap.workItems == null ? 0 : snap.workItems.length;
+    }
+
+    // ------------------------------------------------------------------
+    // GiLightProvider — plain-polygon path, called from parallel
+    // render-pool threads. Must be fast, thread-safe, allocation-free.
+    // ------------------------------------------------------------------
+
+    @Override
+    public boolean isLightVisible(final SolidPolygon polygon, final LightSource light) {
+        final Snapshot snap = snapshot;
+        if (snap == null)
+            return true;
+        final Integer index = snap.lightIndex.get(light);
+        if (index == null || index >= 64)
+            return true;
+        final GiState state = states.get(polygon);
+        if (state == null)
+            return true; // not computed yet: shadows fade in, never pop out
+        final long bit = 1L << index;
+        if ((state.knownBits & bit) == 0)
+            return true;
+        return (state.visibleBits & bit) != 0;
+    }
+
+    @Override
+    public void addIndirectLight(final SolidPolygon polygon, final Color baseColor, final Color result) {
+        final GiState state = states.get(polygon);
+        if (state == null)
+            return;
+        final int r = result.r + (int) (state.indirectR * baseColor.r / 255f);
+        final int g = result.g + (int) (state.indirectG * baseColor.g / 255f);
+        final int b = result.b + (int) (state.indirectB * baseColor.b / 255f);
+        result.set(Math.min(255, r), Math.min(255, g), Math.min(255, b), result.a);
+    }
+
+    // ------------------------------------------------------------------
+    // Worker threads
+    // ------------------------------------------------------------------
+
+    private void workLoop() {
+        final TriangleBvh.Hit hit = new TriangleBvh.Hit();
+        final double[] pos = new double[3];
+        while (running) {
+            try {
+                maybeRebuildSnapshot();
+                final Snapshot snap = snapshot;
+                if (snap == null || snap.workItems.length == 0) {
+                    Thread.sleep(100);
+                    continue;
+                }
+
+                // One sweep over all work items. Convergence is judged by
+                // composite-estimate movement inside updateComposites()
+                // (per-sample deltas are Monte Carlo noise and, with the
+                // fixed alpha, never settle).
+                final long sweepStart = System.currentTimeMillis();
+                final int size = snap.workItems.length;
+                double deltaSum = 0;
+                for (int i = 0; i < size && running; i++) {
+                    final int index = Math.floorMod(workIndex.getAndIncrement(), size);
+                    deltaSum += sample(snap, snap.workItems[index], hit, pos);
+                }
+                final double avgDelta = deltaSum / size;
+
+                // Regenerate composite textures at most every
+                // COMPOSITE_INTERVAL_MS; a paint pass is one texture swap
+                // per triangle, invisible to the render threads. Also
+                // advances the convergence counter.
+                final long now = System.currentTimeMillis();
+                if (now - lastCompositeUpdate >= COMPOSITE_INTERVAL_MS) {
+                    updateComposites(snap);
+                    lastCompositeUpdate = now;
+                }
+
+                if (DEBUG)
+                    System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", avgDelta)
+                            + ", calmSweeps=" + calmSweeps);
+
+                if (isConverged()) {
+                    // Converged: cap duty cycle at ~50% of sweep time
+                    // (a big scene's sweep takes seconds; a flat 250ms
+                    // sleep would barely throttle it). Hard cap keeps
+                    // post-edit re-convergence prompt.
+                    final long sweepMillis = System.currentTimeMillis() - sweepStart;
+                    Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS,
+                            Math.max(IDLE_SLEEP_MS, sweepMillis)));
+                }
+            } catch (final InterruptedException e) {
+                return;
+            } catch (final Exception e) {
+                e.printStackTrace();
+                try {
+                    Thread.sleep(500);
+                } catch (final InterruptedException ie) {
+                    return;
+                }
+            }
+        }
+    }
+
+    /** One progressive sample: one shadow ray + one bounce ray. */
+    private double sample(final Snapshot snap, final WorkItem item,
+                          final TriangleBvh.Hit hit, final double[] pos) {
+        final TriangleBvh.Entry entry = item.entry;
+        final Lightmap lightmap = entry.lightmap;
+
+        final double ox, oy, oz, nx, ny, nz;
+        if (lightmap != null) {
+            lightmap.texelWorldPosition(item.texel, pos);
+            nx = lightmap.normalX;
+            ny = lightmap.normalY;
+            nz = lightmap.normalZ;
+            ox = pos[0] + nx * ORIGIN_EPSILON;
+            oy = pos[1] + ny * ORIGIN_EPSILON;
+            oz = pos[2] + nz * ORIGIN_EPSILON;
+        } else {
+            nx = entry.normal[0];
+            ny = entry.normal[1];
+            nz = entry.normal[2];
+            ox = entry.centroidX + nx * ORIGIN_EPSILON;
+            oy = entry.centroidY + ny * ORIGIN_EPSILON;
+            oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+        }
+
+        // 1. Shadow rays. First visit per texel: test ALL lights, so direct
+        // light + hard shadows appear after one sweep instead of trickling
+        // in over lightCount sweeps. Afterwards: one light, round-robin.
+        final int lightCount = snap.lights.size();
+        if (lightCount > 0) {
+            if (lightmap != null) {
+                lightmap.ensureLightCapacity(lightCount);
+                final boolean firstVisit = lightmap.sampleCounts[item.texel] == 0;
+                if (firstVisit) {
+                    for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
+                        lightmap.lightVisibility[item.texel * lightCount + i] =
+                                shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
+                                        ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
+                } else {
+                    final int lightIdx = lightmap.nextLight++ % lightCount;
+                    lightmap.lightVisibility[item.texel * lightCount + lightIdx] =
+                            shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx))
+                                    ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
+                }
+            } else {
+                final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+                final int lightIdx = state.nextLight++ % lightCount;
+                final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx));
+                final long bit = 1L << lightIdx;
+                synchronized (state) {
+                    state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
+                    state.knownBits |= bit;
+                }
+            }
+        }
+
+        // 2. Bounce ray: cosine-weighted hemisphere around the normal.
+        final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current());
+
+        double targetR = 0, targetG = 0, targetB = 0;
+        if (snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit)) {
+            // Direct irradiance at the hit point (clamped to display range)
+            // plus the hit surface's current indirect estimate.
+            final double[] irr = directIrradiance(snap, hit);
+            final Color hitColor = colorOf(hit.entry);
+            final float hiR, hiG, hiB;
+            if (hit.entry.lightmap != null) {
+                final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ);
+                hiR = hit.entry.lightmap.indirectR[hitTexel];
+                hiG = hit.entry.lightmap.indirectG[hitTexel];
+                hiB = hit.entry.lightmap.indirectB[hitTexel];
+            } else {
+                final GiState hitState = states.get(hit.entry.polygon);
+                hiR = hitState == null ? 0 : hitState.indirectR;
+                hiG = hitState == null ? 0 : hitState.indirectG;
+                hiB = hitState == null ? 0 : hitState.indirectB;
+            }
+            targetR = BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0;
+            targetG = BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0;
+            targetB = BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0;
+        }
+
+        // Inner EMA update. "fixed" mode (default): every ray hit lands
+        // with the same weight forever, so unlit areas keep fading to
+        // darkness at the same rate lit areas brighten. "adaptive" mode:
+        // alpha starts at ~1 and decays with sample count (floored).
+        if (lightmap != null) {
+            final int count = Math.min(32000, ++lightmap.sampleCounts[item.texel]);
+            final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+                    : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+            final float dR = (float) (targetR - lightmap.indirectR[item.texel]);
+            final float dG = (float) (targetG - lightmap.indirectG[item.texel]);
+            final float dB = (float) (targetB - lightmap.indirectB[item.texel]);
+            lightmap.indirectR[item.texel] += alpha * dR;
+            lightmap.indirectG[item.texel] += alpha * dG;
+            lightmap.indirectB[item.texel] += alpha * dB;
+            return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+        } else {
+            final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+            synchronized (state) {
+                final int count = Math.min(32000, ++state.samples);
+                final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+                        : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+                final float dR = (float) (targetR - state.indirectR);
+                final float dG = (float) (targetG - state.indirectG);
+                final float dB = (float) (targetB - state.indirectB);
+                state.indirectR += alpha * dR;
+                state.indirectG += alpha * dG;
+                state.indirectB += alpha * dB;
+                return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+            }
+        }
+    }
+
+    /** Shadow ray from a surface point toward a light. */
+    private boolean shadowTest(final Snapshot snap,
+                               final double ox, final double oy, final double oz,
+                               final double nx, final double ny, final double nz,
+                               final LightSource light) {
+        final Point3D lightPos = light.getPosition();
+        final double dx = lightPos.x - ox;
+        final double dy = lightPos.y - oy;
+        final double dz = lightPos.z - oz;
+        final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+        if (dist < 1.0)
+            return true;
+
+        // Light behind the surface never illuminates it.
+        if ((dx * nx + dy * ny + dz * nz) / dist <= 0)
+            return false;
+
+        final double maxT = dist - LIGHT_EPSILON;
+        if (maxT <= 0)
+            return true;
+        return !snap.bvh.occluded(ox, oy, oz, dx / dist, dy / dist, dz / dist, maxT);
+    }
+
+    /**
+     * Direct irradiance at a ray hit point, same scale as LightingManager,
+     * clamped to display range: light units near a lamp can sum far beyond
+     * 255 and feeding unbounded energy into the bounce loop saturates the
+     * scene. Uses the hit surface's CACHED visibility (no new shadow rays).
+     */
+    private double[] directIrradiance(final Snapshot snap, final TriangleBvh.Hit hit) {
+        final TriangleBvh.Entry entry = hit.entry;
+        final Lightmap lightmap = entry.lightmap;
+
+        double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+        final int lightCount = snap.lights.size();
+        final int hitTexel = lightmap != null
+                ? lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ) : -1;
+        final GiState hitState = lightmap == null ? states.get(entry.polygon) : null;
+
+        for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
+            final LightSource light = snap.lights.get(i);
+
+            if (lightmap != null) {
+                if (lightmap.lightVisibility != null
+                        && lightmap.lightVisibility[hitTexel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
+                    continue;
+            } else if (hitState != null) {
+                final long bit = 1L << i;
+                if ((hitState.knownBits & bit) != 0 && (hitState.visibleBits & bit) == 0)
+                    continue;
+            }
+
+            final Point3D lightPos = light.getPosition();
+            final double dx = lightPos.x - hit.pointX;
+            final double dy = lightPos.y - hit.pointY;
+            final double dz = lightPos.z - hit.pointZ;
+            final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+            if (dist < 0.0001)
+                continue;
+            final double dot = (entry.normal[0] * dx + entry.normal[1] * dy + entry.normal[2] * dz) / dist;
+            if (dot <= 0)
+                continue;
+            final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+            final double intensity = dot * attenuation * light.getIntensity();
+            final Color lightColor = light.getColor();
+            r += lightColor.r * intensity;
+            g += lightColor.g * intensity;
+            b += lightColor.b * intensity;
+        }
+        return new double[]{Math.min(255, r), Math.min(255, g), Math.min(255, b)};
+    }
+
+    /** Surface color of a snapshot entry (albedo source). */
+    private static Color colorOf(final TriangleBvh.Entry entry) {
+        if (entry.lightmap != null)
+            return entry.lightmap.baseColor;
+        return ((SolidPolygon) entry.polygon).getColor();
+    }
+
+    // ------------------------------------------------------------------
+    // Composite textures: baseColor x (ambient + direct with shadows +
+    // indirect), regenerated into the back buffer and swapped in.
+    // ------------------------------------------------------------------
+
+    private void updateComposites(final Snapshot snap) {
+        // Single flight: both workers finish sweeps concurrently and must
+        // not write the same back buffers simultaneously.
+        if (!compositeUpdateInFlight.compareAndSet(false, true))
+            return;
+        try {
+            final int lightCount = snap.lights.size();
+            final double[] pos = new double[3];
+            double movementSum = 0;
+            long texelTotal = 0;
+            for (final Lightmap lightmap : snap.lightmaps) {
+                lightmap.ensureLightCapacity(lightCount);
+                final int width = lightmap.width;
+                final int height = lightmap.height;
+                final int texelCount = width * height;
+
+                // 1. Total irradiance per valid texel (float, no clamping yet).
+                final float[] irrR = new float[texelCount];
+                final float[] irrG = new float[texelCount];
+                final float[] irrB = new float[texelCount];
+                for (final int texel : lightmap.validTexels) {
+                    lightmap.texelWorldPosition(texel, pos);
+                    double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+                    for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
+                        if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
+                            continue;
+                        final LightSource light = snap.lights.get(i);
+                        final Point3D lightPos = light.getPosition();
+                        final double dx = lightPos.x - pos[0];
+                        final double dy = lightPos.y - pos[1];
+                        final double dz = lightPos.z - pos[2];
+                        final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+                        if (dist < 0.0001)
+                            continue;
+                        final double dot = (lightmap.normalX * dx + lightmap.normalY * dy
+                                + lightmap.normalZ * dz) / dist;
+                        if (dot <= 0)
+                            continue;
+                        final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+                        final double intensity = dot * attenuation * light.getIntensity();
+                        final Color lightColor = light.getColor();
+                        r += lightColor.r * intensity;
+                        g += lightColor.g * intensity;
+                        b += lightColor.b * intensity;
+                    }
+                    // Indirect, lightly blended with valid 4-neighbors:
+                    // single-texel Monte Carlo spikes are smoothed without
+                    // blurring real gradients (texels are sub-pixel at 4K).
+                    final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel];
+                    final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel];
+                    final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel];
+                    irrR[texel] = (float) Math.min(255, r) + smoothedR;
+                    irrG[texel] = (float) Math.min(255, g) + smoothedG;
+                    irrB[texel] = (float) Math.min(255, b) + smoothedB;
+                }
+
+                // 2. Fill the invalid half (u+v > 1) from nearest valid
+                //    neighbors, so bilinear upsampling never reads garbage.
+                final boolean[] filled = new boolean[texelCount];
+                for (final int texel : lightmap.validTexels)
+                    filled[texel] = true;
+                boolean progressed = true;
+                while (progressed) {
+                    progressed = false;
+                    for (int t = 0; t < texelCount; t++) {
+                        if (filled[t])
+                            continue;
+                        final int i = t % width;
+                        final int j = t / width;
+                        final int left = i > 0 ? t - 1 : -1;
+                        final int right = i < width - 1 ? t + 1 : -1;
+                        final int up = j > 0 ? t - width : -1;
+                        final int down = j < height - 1 ? t + width : -1;
+                        final int source = left >= 0 && filled[left] ? left
+                                : right >= 0 && filled[right] ? right
+                                : up >= 0 && filled[up] ? up
+                                : down >= 0 && filled[down] ? down : -1;
+                        if (source >= 0) {
+                            irrR[t] = irrR[source];
+                            irrG[t] = irrG[source];
+                            irrB[t] = irrB[source];
+                            filled[t] = true;
+                            progressed = true;
+                        }
+                    }
+                }
+
+                // 3. Blend the computed irradiance into the persistent
+                //    per-texel estimate (the outer EMA), then write the
+                //    composite texture 1:1 from the ESTIMATE — the texture
+                //    can only move COMPOSITE_ALPHA of the remaining
+                //    distance per update, so direct light, shadows and
+                //    indirect all fade in/out gradually.
+                final Texture back = lightmap.backTexture();
+                final int[] pixels = back.primaryBitmap.pixels;
+                for (int j = 0; j < height; j++)
+                    for (int i = 0; i < width; i++) {
+                        final int t = j * width + i;
+                        final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t]));
+                        final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t]));
+                        final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t]));
+                        lightmap.estimateR[t] += dR;
+                        lightmap.estimateG[t] += dG;
+                        lightmap.estimateB[t] += dB;
+                        movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
+                        texelTotal++;
+                        pixels[t] = compositePixel(lightmap,
+                                lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]);
+                    }
+
+                back.resetResampledBitmapCache();
+                if (lightmap.owner != null) {
+                    lightmap.owner.setTexture(back);
+                    lightmap.swapBuffers();
+                }
+
+                // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs.
+                if (DUMP_DIR != null)
+                    dumpLightmap(lightmap, pixels);
+            }
+
+            // Convergence: average per-texel movement of the on-screen
+            // estimate. With a constant alpha the estimate never fully
+            // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the
+            // VISIBLE movement; five calm updates in a row -> idle.
+            final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0;
+            if (avgMovement < CALM_THRESHOLD)
+                calmSweeps++;
+            else
+                calmSweeps = 0;
+            if (DEBUG)
+                System.out.println("[GI] composite update, avgMovement="
+                        + String.format("%.2f", avgMovement));
+        } finally {
+            compositeUpdateInFlight.set(false);
+        }
+    }
+
+    private static final String DUMP_DIR = System.getProperty("e3d.gi.dumpLightmaps");
+    private static int dumpCounter;
+
+    private static void dumpLightmap(final Lightmap lightmap, final int[] pixels) {
+        if (dumpCounter++ % 173 != 0) // spread dumps across lightmaps
+            return;
+        try {
+            final int scale = 8;
+            final int w = lightmap.width;
+            final int h = lightmap.height;
+            final java.awt.image.BufferedImage image = new java.awt.image.BufferedImage(
+                    w * scale, h * scale, java.awt.image.BufferedImage.TYPE_INT_RGB);
+            for (int j = 0; j < h * scale; j++)
+                for (int i = 0; i < w * scale; i++)
+                    image.setRGB(i, j, pixels[(j / scale) * w + (i / scale)]);
+            final java.io.File dir = new java.io.File(DUMP_DIR);
+            dir.mkdirs();
+            final String name = String.format("%s/lm-%03d-%dx%d-(%.0f,%.0f,%.0f).png", DUMP_DIR,
+                    dumpCounter, w, h,
+                    lightmap.originX, lightmap.originY, lightmap.originZ);
+            javax.imageio.ImageIO.write(image, "png", new java.io.File(name));
+        } catch (final Exception e) {
+            e.printStackTrace();
+        }
+    }
+
+    /**
+     * Indirect value blended 50/50 with the mean of valid 4-neighbors.
+     * Kills single-texel Monte Carlo spikes (bright speckles in shadows).
+     */
+    private static float smoothedIndirect(final float[] indirect, final Lightmap lightmap, final int texel) {
+        final int width = lightmap.width;
+        final int height = lightmap.height;
+        final int i = texel % width;
+        final int j = texel / width;
+        float sum = 0;
+        int count = 0;
+        if (i > 0 && isValid(lightmap, texel - 1)) { sum += indirect[texel - 1]; count++; }
+        if (i < width - 1 && isValid(lightmap, texel + 1)) { sum += indirect[texel + 1]; count++; }
+        if (j > 0 && isValid(lightmap, texel - width)) { sum += indirect[texel - width]; count++; }
+        if (j < height - 1 && isValid(lightmap, texel + width)) { sum += indirect[texel + width]; count++; }
+        if (count == 0)
+            return indirect[texel];
+        return 0.5f * indirect[texel] + 0.5f * sum / count;
+    }
+
+    private static boolean isValid(final Lightmap lightmap, final int texel) {
+        final double u = ((texel % lightmap.width) + 0.5) / lightmap.width;
+        final double v = ((texel / lightmap.width) + 0.5) / lightmap.height;
+        return u + v <= 1.0;
+    }
+
+    /** Composite texel: baseColor scaled by total irradiance, clamped. */
+    private static int compositePixel(final Lightmap lightmap,
+                                      final double irrR, final double irrG, final double irrB) {
+        final int r = Math.min(255, (int) (irrR * lightmap.baseColor.r / 255));
+        final int g = Math.min(255, (int) (irrG * lightmap.baseColor.g / 255));
+        final int b = Math.min(255, (int) (irrB * lightmap.baseColor.b / 255));
+        return 0xFF000000 | (r << 16) | (g << 8) | b;
+    }
+
+    // ------------------------------------------------------------------
+    // Snapshot management
+    // ------------------------------------------------------------------
+
+    private void maybeRebuildSnapshot() {
+        final int version = AbstractCompositeShape.getGlobalRenderListVersion();
+        final double lightSignature = lightSignature();
+        if (version == lastSeenRenderListVersion && lightSignature == lastLightSignature)
+            return;
+
+        final List<AbstractCoordinateShape> triangles = new ArrayList<>();
+        shapes.collectRenderTriangles(triangles);
+
+        final Snapshot snap = new Snapshot();
+        snap.entries = new ArrayList<>(triangles.size());
+        snap.lightmaps = new ArrayList<>();
+        for (final AbstractCoordinateShape triangle : triangles) {
+            if (triangle.vertices.size() < 3)
+                continue;
+            final TriangleBvh.Entry entry = buildEntry(triangle);
+            snap.entries.add(entry);
+            if (entry.lightmap != null)
+                snap.lightmaps.add(entry.lightmap);
+        }
+        if (!snap.entries.isEmpty())
+            snap.bvh = new TriangleBvh(snap.entries);
+        snap.lights = new ArrayList<>(lightingManager.getLights());
+        snap.lightIndex = new IdentityHashMap<>();
+        for (int i = 0; i < snap.lights.size(); i++)
+            snap.lightIndex.put(snap.lights.get(i), i);
+        final Color ambient = lightingManager.getAmbientLight();
+        snap.ambientR = ambient.r;
+        snap.ambientG = ambient.g;
+        snap.ambientB = ambient.b;
+
+        // Flattened work list: one item per valid lightmap texel,
+        // one per plain polygon.
+        final List<WorkItem> workItems = new ArrayList<>();
+        for (final TriangleBvh.Entry entry : snap.entries) {
+            if (entry.lightmap != null) {
+                for (final int texel : entry.lightmap.validTexels) {
+                    final WorkItem item = new WorkItem();
+                    item.entry = entry;
+                    item.texel = texel;
+                    workItems.add(item);
+                }
+            } else {
+                final WorkItem item = new WorkItem();
+                item.entry = entry;
+                item.texel = -1;
+                workItems.add(item);
+            }
+        }
+        snap.workItems = workItems.toArray(new WorkItem[0]);
+
+        snapshot = snap;
+        states.clear();
+        lastSeenRenderListVersion = version;
+        lastLightSignature = lightSignature;
+        calmSweeps = 0; // scene changed: back to full-speed tracing
+
+        if (DEBUG)
+            System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+                    + snap.workItems.length + " work items, "
+                    + snap.lightmaps.size() + " lightmaps, "
+                    + snap.lights.size() + " lights");
+    }
+
+    private double lightSignature() {
+        double signature = 0;
+        for (final LightSource light : lightingManager.getLights()) {
+            final Point3D p = light.getPosition();
+            final Color c = light.getColor();
+            signature += p.x + p.y + p.z + c.r + c.g + c.b + light.getIntensity() * 31.0;
+        }
+        return signature;
+    }
+
+    private TriangleBvh.Entry buildEntry(final AbstractCoordinateShape triangle) {
+        final TriangleBvh.Entry entry = new TriangleBvh.Entry(triangle);
+        if (triangle instanceof LightmappedShape)
+            entry.lightmap = ((LightmappedShape) triangle).getLightmap();
+
+        final Point3D a = triangle.vertices.get(0).coordinate;
+        final Point3D b = triangle.vertices.get(1).coordinate;
+        final Point3D c = triangle.vertices.get(2).coordinate;
+        entry.v[0] = (float) a.x; entry.v[1] = (float) a.y; entry.v[2] = (float) a.z;
+        entry.v[3] = (float) b.x; entry.v[4] = (float) b.y; entry.v[5] = (float) b.z;
+        entry.v[6] = (float) c.x; entry.v[7] = (float) c.y; entry.v[8] = (float) c.z;
+        entry.centroidX = (float) ((a.x + b.x + c.x) / 3);
+        entry.centroidY = (float) ((a.y + b.y + c.y) / 3);
+        entry.centroidZ = (float) ((a.z + b.z + c.z) / 3);
+        entry.minX = Math.min(entry.v[0], Math.min(entry.v[3], entry.v[6]));
+        entry.maxX = Math.max(entry.v[0], Math.max(entry.v[3], entry.v[6]));
+        entry.minY = Math.min(entry.v[1], Math.min(entry.v[4], entry.v[7]));
+        entry.maxY = Math.max(entry.v[1], Math.max(entry.v[4], entry.v[7]));
+        entry.minZ = Math.min(entry.v[2], Math.min(entry.v[5], entry.v[8]));
+        entry.maxZ = Math.max(entry.v[2], Math.max(entry.v[5], entry.v[8]));
+        // Normal: right-handed cross(b-a, c-a), matching Plane.computeNormal.
+        final double e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
+        final double e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
+        double nx = e1y * e2z - e1z * e2y;
+        double ny = e1z * e2x - e1x * e2z;
+        double nz = e1x * e2y - e1y * e2x;
+        final double len = Math.sqrt(nx * nx + ny * ny + nz * nz);
+        if (len > 0) {
+            nx /= len;
+            ny /= len;
+            nz /= len;
+        }
+        entry.normal = new float[]{(float) nx, (float) ny, (float) nz};
+        return entry;
+    }
+
+    /** Cosine-weighted hemisphere direction around a normal. */
+    private double[] cosineHemisphere(final double nx, final double ny, final double nz,
+                                      final ThreadLocalRandom random) {
+        final double u = random.nextDouble();
+        final double v = random.nextDouble();
+        final double r = Math.sqrt(u);
+        final double theta = 2 * Math.PI * v;
+        final double x = r * Math.cos(theta);
+        final double y = r * Math.sin(theta);
+        final double z = Math.sqrt(Math.max(0, 1 - u));
+
+        // Orthonormal basis around the normal.
+        final double upX = Math.abs(ny) < 0.9 ? 0 : 1;
+        final double upY = Math.abs(ny) < 0.9 ? 1 : 0;
+        double tx = upY * nz;
+        double ty = -upX * nz;
+        double tz = upX * ny - upY * nx;
+        final double tLen = Math.sqrt(tx * tx + ty * ty + tz * tz);
+        tx /= tLen;
+        ty /= tLen;
+        tz /= tLen;
+        final double bx = ny * tz - nz * ty;
+        final double by = nz * tx - nx * tz;
+        final double bz = nx * ty - ny * tx;
+
+        return new double[]{
+                tx * x + bx * y + nx * z,
+                ty * x + by * y + ny * z,
+                tz * x + bz * y + nz * z
+        };
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
new file mode 100644 (file)
index 0000000..4acf19e
--- /dev/null
@@ -0,0 +1,279 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+/**
+ * Per-triangle lightmap: a small generated texture whose texels map onto the
+ * triangle surface. The triangle's UVs (in texture pixel units) are
+ * (0,0), (width,0), (0,height), so the valid texel region is the half
+ * where u+v &lt;= 1 in normalized coordinates; the other half is filled
+ * by mirroring to keep mipmaps and edge sampling clean.
+ *
+ * <p>What you trace is what you see: the composite texture the painter
+ * samples IS the lightmap, at native texel resolution. Shadow-edge
+ * smoothness comes from tracing at finer resolution (smaller
+ * unitsPerTexel), never from upsampling.</p>
+ *
+ * <p>The GI system stores per-texel indirect irradiance and per-texel
+ * per-light visibility here, and periodically regenerates the premultiplied
+ * composite texture (baseColor x lighting) into the back buffer, then swaps
+ * it onto the rendered triangle — painters never see a half-updated
+ * texture.</p>
+ *
+ * <p><b>Gradual convergence:</b> what reaches the texture is never the raw
+ * computed irradiance but a persistent per-texel exponential moving average
+ * ({@link #estimateR}/{@link #estimateG}/{@link #estimateB}) over the
+ * complete sum ambient+direct+indirect. The estimate starts at a uniform
+ * medium value ({@link #INITIAL_IRRADIANCE}), so the world is visible from
+ * frame one; lit areas then brighten and unlit areas sink to darkness
+ * gradually — no black-to-lit flash is possible, since the texture can only
+ * move a fixed alpha fraction per composite update.</p>
+ *
+ * <p>Threading: texel state arrays are written by GI worker threads and read
+ * by whoever holds the snapshot; element-wise racy access is benign for
+ * progressive refinement. Texture buffer swaps are volatile/atomic via
+ * {@link LightmappedTriangle#setTexture}.</p>
+ */
+public class Lightmap {
+
+    /** Visibility value: not yet computed. */
+    public static final byte VISIBILITY_UNKNOWN = 0;
+    /** Visibility value: shadow ray reached the light. */
+    public static final byte VISIBILITY_VISIBLE = 1;
+    /** Visibility value: shadow ray was blocked. */
+    public static final byte VISIBILITY_OCCLUDED = 2;
+
+    /** Texture size limits, power of two. */
+    private static final int MIN_SIZE = 4;
+    private static final int MAX_SIZE = 128;
+
+    /**
+     * Uniform irradiance the composite estimate starts at (light units,
+     * display scale 0..255): the world begins medium-lit and visible, then
+     * fades toward the traced solution. {@code -De3d.gi.initialIrradiance}.
+     */
+    public static final double INITIAL_IRRADIANCE =
+            Double.parseDouble(System.getProperty("e3d.gi.initialIrradiance", "128"));
+
+    public final int width;
+    public final int height;
+
+    /** Unlit surface color of the triangle. */
+    public final Color baseColor;
+
+    // World mapping: p(u,v) = origin + e1*u + e2*v.
+    public final double originX, originY, originZ;
+    public final double edge1X, edge1Y, edge1Z;
+    public final double edge2X, edge2Y, edge2Z;
+    /** Unit surface normal. */
+    public final double normalX, normalY, normalZ;
+
+    /** Valid (u+v &lt;= 1) texel indices, for round-robin sampling. */
+    public final int[] validTexels;
+
+    /** Per-texel indirect irradiance (light units, pre-albedo). */
+    public final float[] indirectR;
+    public final float[] indirectG;
+    public final float[] indirectB;
+
+    /**
+     * Per-texel EMA estimate of TOTAL irradiance (ambient + direct +
+     * indirect), the only value ever written to the composite texture.
+     * Initialized to {@link #INITIAL_IRRADIANCE} (uniform medium start);
+     * each composite update blends the freshly computed irradiance in with
+     * a fixed alpha, so both brightening and fading to darkness stay alive
+     * forever and no single-frame jump can occur.
+     */
+    public final float[] estimateR;
+    public final float[] estimateG;
+    public final float[] estimateB;
+
+    /**
+     * Per-texel sample counters. In "adaptive" alpha mode they drive the
+     * decaying EMA weight; in the default "fixed" mode they only mark
+     * first-visit texels (all-lights shadow test on the first sweep).
+     */
+    public final short[] sampleCounts;
+
+    /** Per-texel per-light visibility: texelCount * lightCount bytes. */
+    public byte[] lightVisibility;
+    public int lightCount;
+
+    /** Double-buffered composite textures; the triangle shows one, GI fills the other. */
+    private final Texture[] buffers = new Texture[2];
+    private int shownBuffer;
+
+    /** The triangle currently displaying this lightmap (for texture swaps). */
+    public volatile LightmappedTriangle owner;
+
+    /** Round-robin light cursor for shadow sampling (GI threads only). */
+    public int nextLight;
+
+    /**
+     * Creates a lightmap for a triangle.
+     *
+     * @param a              first vertex (UV 0,0)
+     * @param b              second vertex (UV 1,0)
+     * @param c              third vertex (UV 0,1)
+     * @param baseColor      unlit surface color
+     * @param unitsPerTexel  world units per lightmap texel (resolution knob)
+     * @param normalX        unit normal x
+     * @param normalY        unit normal y
+     * @param normalZ        unit normal z
+     */
+    public Lightmap(final Point3D a, final Point3D b, final Point3D c,
+                    final Color baseColor, final double unitsPerTexel,
+                    final double normalX, final double normalY, final double normalZ) {
+        this.baseColor = baseColor;
+        originX = a.x;
+        originY = a.y;
+        originZ = a.z;
+        edge1X = b.x - a.x;
+        edge1Y = b.y - a.y;
+        edge1Z = b.z - a.z;
+        edge2X = c.x - a.x;
+        edge2Y = c.y - a.y;
+        edge2Z = c.z - a.z;
+        this.normalX = normalX;
+        this.normalY = normalY;
+        this.normalZ = normalZ;
+
+        final double len1 = Math.sqrt(edge1X * edge1X + edge1Y * edge1Y + edge1Z * edge1Z);
+        final double len2 = Math.sqrt(edge2X * edge2X + edge2Y * edge2Y + edge2Z * edge2Z);
+        width = powerOfTwo(len1 / unitsPerTexel);
+        height = powerOfTwo(len2 / unitsPerTexel);
+
+        indirectR = new float[width * height];
+        indirectG = new float[width * height];
+        indirectB = new float[width * height];
+        estimateR = new float[width * height];
+        estimateG = new float[width * height];
+        estimateB = new float[width * height];
+        java.util.Arrays.fill(estimateR, (float) INITIAL_IRRADIANCE);
+        java.util.Arrays.fill(estimateG, (float) INITIAL_IRRADIANCE);
+        java.util.Arrays.fill(estimateB, (float) INITIAL_IRRADIANCE);
+        sampleCounts = new short[width * height];
+
+        final int[] valid = new int[width * height];
+        int count = 0;
+        for (int j = 0; j < height; j++)
+            for (int i = 0; i < width; i++) {
+                final double u = (i + 0.5) / width;
+                final double v = (j + 0.5) / height;
+                if (u + v <= 1.0)
+                    valid[count++] = j * width + i;
+            }
+        validTexels = new int[count];
+        System.arraycopy(valid, 0, validTexels, 0, count);
+
+        // Both buffers start at the uniform medium INITIAL_IRRADIANCE:
+        // the world is visible from frame one and fades toward the traced
+        // solution (lit areas brighten, unlit areas sink to darkness).
+        buffers[0] = createTexture();
+        buffers[1] = createTexture();
+    }
+
+    private static int powerOfTwo(final double size) {
+        int result = MIN_SIZE;
+        while (result < size && result < MAX_SIZE)
+            result <<= 1;
+        return result;
+    }
+
+    private Texture createTexture() {
+        final Texture texture = new Texture(width, height, 0);
+        final int r = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.r / 255));
+        final int g = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.g / 255));
+        final int b = Math.min(255, (int) (INITIAL_IRRADIANCE * baseColor.b / 255));
+        final int pixel = 0xFF000000 | (r << 16) | (g << 8) | b;
+        java.util.Arrays.fill(texture.primaryBitmap.pixels, pixel);
+        return texture;
+    }
+
+    /**
+     * World position of a texel center.
+     *
+     * @param texel texel index (j * width + i)
+     * @param out   receives x, y, z
+     */
+    public void texelWorldPosition(final int texel, final double[] out) {
+        final double u = ((texel % width) + 0.5) / width;
+        final double v = ((texel / width) + 0.5) / height;
+        out[0] = originX + edge1X * u + edge2X * v;
+        out[1] = originY + edge1Y * u + edge2Y * v;
+        out[2] = originZ + edge1Z * u + edge2Z * v;
+    }
+
+    /**
+     * Texel index nearest to a world point on the triangle plane.
+     *
+     * @param px world x
+     * @param py world y
+     * @param pz world z
+     * @return texel index, clamped into the texture
+     */
+    public int texelAt(final double px, final double py, final double pz) {
+        final double dx = px - originX;
+        final double dy = py - originY;
+        final double dz = pz - originZ;
+        final double d11 = edge1X * edge1X + edge1Y * edge1Y + edge1Z * edge1Z;
+        final double d22 = edge2X * edge2X + edge2Y * edge2Y + edge2Z * edge2Z;
+        final double d12 = edge1X * edge2X + edge1Y * edge2Y + edge1Z * edge2Z;
+        final double dp1 = dx * edge1X + dy * edge1Y + dz * edge1Z;
+        final double dp2 = dx * edge2X + dy * edge2Y + dz * edge2Z;
+        final double denom = d11 * d22 - d12 * d12;
+        if (denom < 1e-12)
+            return 0;
+        final double u = (dp1 * d22 - dp2 * d12) / denom;
+        final double v = (dp2 * d11 - dp1 * d12) / denom;
+        int i = (int) (u * width);
+        int j = (int) (v * height);
+        if (i < 0) i = 0;
+        if (i >= width) i = width - 1;
+        if (j < 0) j = 0;
+        if (j >= height) j = height - 1;
+        return j * width + i;
+    }
+
+    /**
+     * Ensures the per-texel visibility array matches the light count.
+     * Called from GI threads during sampling.
+     *
+     * @param lights number of lights in the snapshot
+     */
+    public void ensureLightCapacity(final int lights) {
+        if (lightVisibility == null || lightCount != lights) {
+            lightVisibility = new byte[width * height * lights];
+            lightCount = lights;
+        }
+    }
+
+    /**
+     * The texture the triangle should show right now.
+     *
+     * @return the front composite texture
+     */
+    public Texture shownTexture() {
+        return buffers[shownBuffer];
+    }
+
+    /**
+     * The texture GI should write the next composite into.
+     *
+     * @return the back composite texture
+     */
+    public Texture backTexture() {
+        return buffers[1 - shownBuffer];
+    }
+
+    /** Flips the buffers after the back texture has been regenerated. */
+    public void swapBuffers() {
+        shownBuffer = 1 - shownBuffer;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedShape.java
new file mode 100644 (file)
index 0000000..bdf50bd
--- /dev/null
@@ -0,0 +1,20 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
+
+/**
+ * A shape carrying a {@link Lightmap}. The global illumination system
+ * detects this interface in its scene snapshot and samples GI per lightmap
+ * texel instead of per polygon.
+ */
+public interface LightmappedShape {
+
+    /**
+     * Returns the lightmap for this shape.
+     *
+     * @return the lightmap
+     */
+    Lightmap getLightmap();
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.java
new file mode 100644 (file)
index 0000000..e285b97
--- /dev/null
@@ -0,0 +1,65 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+
+/**
+ * A textured triangle whose texture is a GI-generated lightmap composite
+ * (baseColor x lighting). UVs are fixed at (0,0), (width,0), (0,height)
+ * (engine UVs are in texture pixel units): the whole triangle is covered by
+ * its own lightmap, valid region u+v &lt;= 1 in normalized coordinates.
+ *
+ * <p>Shading via {@code LightingManager} does not apply — all light (ambient,
+ * direct with shadows, indirect) lives in the composite texture, which the
+ * GI system regenerates and swaps in every few sweeps.</p>
+ */
+public class LightmappedTriangle extends TexturedTriangle implements LightmappedShape {
+
+    private final Lightmap lightmap;
+
+    /**
+     * Creates a lightmapped triangle.
+     *
+     * @param a             first vertex (shared coordinate reference is fine)
+     * @param b             second vertex
+     * @param c             third vertex
+     * @param baseColor     unlit surface color
+     * @param unitsPerTexel world units per lightmap texel
+     * @param normalX       unit normal x
+     * @param normalY       unit normal y
+     * @param normalZ       unit normal z
+     */
+    public LightmappedTriangle(final Point3D a, final Point3D b, final Point3D c,
+                               final Color baseColor, final double unitsPerTexel,
+                               final double normalX, final double normalY, final double normalZ) {
+        super(new Vertex(a, new Point2D(0, 0)),
+                new Vertex(b, new Point2D(1, 0)),
+                new Vertex(c, new Point2D(0, 1)),
+                null);
+        lightmap = new Lightmap(a, b, c, baseColor, unitsPerTexel,
+                normalX, normalY, normalZ);
+        lightmap.owner = this;
+
+        // UVs are in PRIMARY TEXTURE PIXELS in this engine (multiplication
+        // factor scales them into the selected mip level), so the triangle
+        // corners map to the lightmap corners directly.
+        vertices.get(0).textureCoordinate = new Point2D(0, 0);
+        vertices.get(1).textureCoordinate = new Point2D(lightmap.width, 0);
+        vertices.get(2).textureCoordinate = new Point2D(0, lightmap.height);
+        refreshTextureDistance();
+
+        setTexture(lightmap.shownTexture());
+    }
+
+    @Override
+    public Lightmap getLightmap() {
+        return lightmap;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java
new file mode 100644 (file)
index 0000000..5e099ae
--- /dev/null
@@ -0,0 +1,231 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.util.List;
+
+/**
+ * Bounding volume hierarchy over world-space triangles for fast ray queries.
+ * Used by the global illumination system; deliberately separate from the
+ * voxel octree (which serves voxel tracing and stays untouched).
+ *
+ * <p>Build: top-down median split along the longest AABB axis.
+ * Queries: nearest-hit for bounce rays, any-hit with early out for shadow
+ * rays. Intersection is Möller–Trumbore, two-sided (walls are single quads
+ * that must occlude from both sides). Not thread-safe to build, safe to
+ * query concurrently once published via a volatile/atomic reference.</p>
+ */
+public class TriangleBvh {
+
+    /** One ray-traced triangle with cached data. */
+    public static class Entry {
+        public final AbstractCoordinateShape polygon;
+        /** Lightmap when the polygon is a {@link LightmappedShape}, else null. */
+        public Lightmap lightmap;
+        /** Triangle vertices, world space: x0,y0,z0, x1,y1,z1, x2,y2,z2. */
+        public final float[] v = new float[9];
+        public float centroidX, centroidY, centroidZ;
+        public float minX, minY, minZ, maxX, maxY, maxZ;
+        /** Unit surface normal, world space. */
+        public volatile float[] normal;
+
+        public Entry(final AbstractCoordinateShape polygon) {
+            this.polygon = polygon;
+        }
+    }
+
+    /** Nearest-hit query result. */
+    public static class Hit {
+        public Entry entry;
+        public double t;
+        public double pointX, pointY, pointZ;
+    }
+
+    private static class Node {
+        double minX, minY, minZ, maxX, maxY, maxZ;
+        Node left, right;
+        Entry[] entries; // leaf only
+    }
+
+    private static final int LEAF_SIZE = 4;
+    private static final double EPSILON = 0.01;
+
+    private final Node root;
+
+    /**
+     * Builds the tree over the given triangle entries.
+     *
+     * @param entries triangles to index (must not be empty)
+     */
+    public TriangleBvh(final List<Entry> entries) {
+        root = build(entries.toArray(new Entry[0]), 0, entries.size());
+    }
+
+    private Node build(final Entry[] entries, final int from, final int to) {
+        final Node node = new Node();
+        double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, minZ = Double.MAX_VALUE;
+        double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, maxZ = -Double.MAX_VALUE;
+        for (int i = from; i < to; i++) {
+            final Entry e = entries[i];
+            minX = Math.min(minX, e.minX); maxX = Math.max(maxX, e.maxX);
+            minY = Math.min(minY, e.minY); maxY = Math.max(maxY, e.maxY);
+            minZ = Math.min(minZ, e.minZ); maxZ = Math.max(maxZ, e.maxZ);
+        }
+        node.minX = minX; node.minY = minY; node.minZ = minZ;
+        node.maxX = maxX; node.maxY = maxY; node.maxZ = maxZ;
+
+        final int count = to - from;
+        if (count <= LEAF_SIZE) {
+            node.entries = new Entry[count];
+            System.arraycopy(entries, from, node.entries, 0, count);
+            return node;
+        }
+
+        // Split along the longest axis at the median centroid.
+        final double dx = maxX - minX, dy = maxY - minY, dz = maxZ - minZ;
+        final int axis = (dx >= dy && dx >= dz) ? 0 : (dy >= dz ? 1 : 2);
+        java.util.Arrays.sort(entries, from, to, (a, b) -> {
+            final double ca = axis == 0 ? a.centroidX : axis == 1 ? a.centroidY : a.centroidZ;
+            final double cb = axis == 0 ? b.centroidX : axis == 1 ? b.centroidY : b.centroidZ;
+            return Double.compare(ca, cb);
+        });
+        final int mid = from + count / 2;
+        node.left = build(entries, from, mid);
+        node.right = build(entries, mid, to);
+        return node;
+    }
+
+    /**
+     * Finds the nearest triangle hit along the ray, or null.
+     *
+     * @param hit reusable result object, filled on hit
+     * @return true on hit
+     */
+    public boolean nearest(final double ox, final double oy, final double oz,
+                           final double dx, final double dy, final double dz,
+                           final Hit hit) {
+        hit.t = Double.MAX_VALUE;
+        hit.entry = null;
+        nearestNode(root, ox, oy, oz, dx, dy, dz, hit);
+        if (hit.entry == null)
+            return false;
+        hit.pointX = ox + dx * hit.t;
+        hit.pointY = oy + dy * hit.t;
+        hit.pointZ = oz + dz * hit.t;
+        return true;
+    }
+
+    private void nearestNode(final Node node, final double ox, final double oy, final double oz,
+                             final double dx, final double dy, final double dz, final Hit hit) {
+        if (!rayBox(node, ox, oy, oz, dx, dy, dz, hit.t))
+            return;
+
+        if (node.entries != null) {
+            for (final Entry e : node.entries) {
+                final double t = rayTriangle(ox, oy, oz, dx, dy, dz, e.v);
+                if (t > EPSILON && t < hit.t) {
+                    hit.t = t;
+                    hit.entry = e;
+                }
+            }
+            return;
+        }
+        nearestNode(node.left, ox, oy, oz, dx, dy, dz, hit);
+        nearestNode(node.right, ox, oy, oz, dx, dy, dz, hit);
+    }
+
+    /**
+     * Any-hit shadow query: is the segment from the origin to
+     * {@code maxT} along the direction blocked?
+     *
+     * @return true if any triangle intersects the segment
+     */
+    public boolean occluded(final double ox, final double oy, final double oz,
+                            final double dx, final double dy, final double dz,
+                            final double maxT) {
+        return occludedNode(root, ox, oy, oz, dx, dy, dz, maxT);
+    }
+
+    private boolean occludedNode(final Node node, final double ox, final double oy, final double oz,
+                                 final double dx, final double dy, final double dz, final double maxT) {
+        if (!rayBox(node, ox, oy, oz, dx, dy, dz, maxT))
+            return false;
+
+        if (node.entries != null) {
+            for (final Entry e : node.entries) {
+                final double t = rayTriangle(ox, oy, oz, dx, dy, dz, e.v);
+                if (t > EPSILON && t < maxT)
+                    return true;
+            }
+            return false;
+        }
+        return occludedNode(node.left, ox, oy, oz, dx, dy, dz, maxT)
+                || occludedNode(node.right, ox, oy, oz, dx, dy, dz, maxT);
+    }
+
+    /** Slab test: does the ray hit the node box before limitT? */
+    private boolean rayBox(final Node node, final double ox, final double oy, final double oz,
+                           final double dx, final double dy, final double dz, final double limitT) {
+        double tMin = 0, tMax = limitT;
+
+        double t1 = (node.minX - ox) / dx;
+        double t2 = (node.maxX - ox) / dx;
+        if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY;
+        if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY;
+        tMin = Math.max(tMin, Math.min(t1, t2));
+        tMax = Math.min(tMax, Math.max(t1, t2));
+
+        t1 = (node.minY - oy) / dy;
+        t2 = (node.maxY - oy) / dy;
+        if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY;
+        if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY;
+        tMin = Math.max(tMin, Math.min(t1, t2));
+        tMax = Math.min(tMax, Math.max(t1, t2));
+
+        t1 = (node.minZ - oz) / dz;
+        t2 = (node.maxZ - oz) / dz;
+        if (Double.isNaN(t1)) t1 = Double.NEGATIVE_INFINITY;
+        if (Double.isNaN(t2)) t2 = Double.POSITIVE_INFINITY;
+        tMin = Math.max(tMin, Math.min(t1, t2));
+        tMax = Math.min(tMax, Math.max(t1, t2));
+
+        return tMax >= tMin && tMax > EPSILON;
+    }
+
+    /** Möller–Trumbore, two-sided. Returns t or -1. */
+    private double rayTriangle(final double ox, final double oy, final double oz,
+                               final double dx, final double dy, final double dz,
+                               final float[] v) {
+        final double e1x = v[3] - v[0], e1y = v[4] - v[1], e1z = v[5] - v[2];
+        final double e2x = v[6] - v[0], e2y = v[7] - v[1], e2z = v[8] - v[2];
+
+        final double px = dy * e2z - dz * e2y;
+        final double py = dz * e2x - dx * e2z;
+        final double pz = dx * e2y - dy * e2x;
+
+        final double det = e1x * px + e1y * py + e1z * pz;
+        if (det > -1e-12 && det < 1e-12)
+            return -1;
+
+        final double invDet = 1.0 / det;
+        final double tx = ox - v[0], ty = oy - v[1], tz = oz - v[2];
+        final double u = (tx * px + ty * py + tz * pz) * invDet;
+        if (u < 0 || u > 1)
+            return -1;
+
+        final double qx = ty * e1z - tz * e1y;
+        final double qy = tz * e1x - tx * e1z;
+        final double qz = tx * e1y - ty * e1x;
+        final double vv = (dx * qx + dy * qy + dz * qz) * invDet;
+        if (vv < 0 || u + vv > 1)
+            return -1;
+
+        final double t = (e2x * qx + e2y * qy + e2z * qz) * invDet;
+        return t > 0 ? t : -1;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/package-info.java
new file mode 100644 (file)
index 0000000..daa9df9
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Progressive CPU global illumination.
+ *
+ * <p>{@link eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination}
+ * runs Monte Carlo ray casting on dedicated low-priority threads against a
+ * {@link eu.svjatoslav.aukio.e3d.renderer.raster.gi.TriangleBvh} built over
+ * the rendered polygons, and feeds per-polygon direct-light visibility
+ * (shadows) and indirect irradiance (bounced light) into the shading path
+ * through
+ * {@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.GiLightProvider}.
+ * Rendering itself never casts rays; illumination converges progressively
+ * and adapts to scene changes over time.</p>
+ *
+ * <p>GI is strictly opt-in: enable it with
+ * {@code viewPanel.enableGlobalIllumination()}.</p>
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.gi;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.java
new file mode 100644 (file)
index 0000000..f30cf3f
--- /dev/null
@@ -0,0 +1,50 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.lighting;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+/**
+ * Optional provider of global-illumination data for
+ * {@link LightingManager}. When a provider is installed, the per-polygon
+ * lighting computation additionally:
+ *
+ * <ul>
+ *   <li>asks the provider whether each light source is occluded from the
+ *       polygon (direct-light shadows), and</li>
+ *   <li>adds the provider's indirect (bounced light) contribution.</li>
+ * </ul>
+ *
+ * <p>Implementations are called from parallel render-pool threads: all
+ * methods must be thread-safe, fast and allocation-free. Providers are
+ * expected to answer from progressively updated caches.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination the progressive CPU global illumination system
+ */
+public interface GiLightProvider {
+
+    /**
+     * Tells whether a light source currently has an unobstructed path to
+     * the polygon. Until the provider has computed the answer, it should
+     * return {@code true} (light visible): shadows then fade in gradually
+     * instead of popping out.
+     *
+     * @param polygon the shaded polygon
+     * @param light   the light source being evaluated
+     * @return true if the light reaches the polygon
+     */
+    boolean isLightVisible(SolidPolygon polygon, LightSource light);
+
+    /**
+     * Adds the polygon's indirect (bounced light) contribution to an
+     * already computed direct-lighting color, in place.
+     *
+     * @param polygon   the shaded polygon
+     * @param baseColor the polygon's unlit color (for albedo scaling)
+     * @param result    the direct-lighted color to augment (modified in place)
+     */
+    void addIndirectLight(SolidPolygon polygon, Color baseColor, Color result);
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightSource.java
new file mode 100644 (file)
index 0000000..2bde5e6
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.lighting;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * Represents a light source in the 3D scene with position, color, and intensity.
+ *
+ * <p>Light sources emit colored light that illuminates polygons based on their
+ * orientation relative to the light. The intensity of illumination follows the
+ * Lambert cosine law - surfaces facing the light receive full intensity, while
+ * surfaces at an angle receive proportionally less light.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a yellow light source at position (100, -50, 200)
+ * LightSource light = new LightSource(
+ *     new Point3D(100, -50, 200),
+ *     Color.YELLOW,
+ *     1.5
+ * );
+ *
+ * // Move the light source
+ * light.setPosition(new Point3D(0, 0, 300));
+ *
+ * // Change the light color
+ * light.setColor(new Color(255, 100, 50));
+ *
+ * // Adjust intensity
+ * light.setIntensity(2.0);
+ * }</pre>
+ *
+ * @see LightingManager manages multiple light sources and calculates shading
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ */
+public class LightSource {
+
+    /**
+     * Position of the light source in 3D world space.
+     */
+    private Point3D position;
+
+    /**
+     * Color of the light emitted by this source.
+     */
+    private Color color;
+
+    /**
+     * Intensity multiplier for this light source.
+     * Values greater than 1.0 make the light brighter, values less than 1.0 make it dimmer.
+     * High intensity values can cause surfaces to appear white (clamped at 255).
+     */
+    private double intensity;
+
+    /**
+     * Creates a new light source at the specified position with the given color and intensity.
+     *
+     * @param position the position of the light in world space
+     * @param color    the color of the light
+     * @param intensity the intensity multiplier (1.0 = normal brightness)
+     */
+    public LightSource(final Point3D position, final Color color, final double intensity) {
+        this.position = position;
+        this.color = color;
+        this.intensity = intensity;
+    }
+
+    /**
+     * Creates a new light source at the specified position with the given color.
+     * Default intensity is 1.0.
+     *
+     * @param position the position of the light in world space
+     * @param color    the color of the light
+     */
+    public LightSource(final Point3D position, final Color color) {
+        this(position, color, 1.0);
+    }
+
+    /**
+     * Returns the color of this light source.
+     *
+     * @return the light color
+     */
+    public Color getColor() {
+        return color;
+    }
+
+    /**
+     * Returns the intensity multiplier of this light source.
+     *
+     * @return the intensity multiplier
+     */
+    public double getIntensity() {
+        return intensity;
+    }
+
+    /**
+     * Returns the position of this light source.
+     *
+     * @return the position in world space
+     */
+    public Point3D getPosition() {
+        return position;
+    }
+
+    /**
+     * Sets the color of this light source.
+     *
+     * @param color the new light color
+     */
+    public void setColor(final Color color) {
+        this.color = color;
+    }
+
+    /**
+     * Sets the intensity multiplier of this light source.
+     *
+     * @param intensity the new intensity multiplier (1.0 = normal brightness)
+     */
+    public void setIntensity(final double intensity) {
+        this.intensity = intensity;
+    }
+
+    /**
+     * Sets the position of this light source.
+     *
+     * @param position the new position in world space
+     */
+    public void setPosition(final Point3D position) {
+        this.position = position;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/LightingManager.java
new file mode 100644 (file)
index 0000000..6ec3972
--- /dev/null
@@ -0,0 +1,278 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.lighting;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Manages light sources in the scene and calculates lighting for polygons.
+ *
+ * <p>This class implements flat shading using the Lambert cosine law. For each
+ * polygon face, it calculates the surface normal and determines how much light
+ * each source contributes based on the angle between the normal and the light
+ * direction.</p>
+ *
+ * <p>The lighting calculation considers:</p>
+ * <ul>
+ * <li>Distance from polygon center to each light source</li>
+ * <li>Angle between surface normal and light direction</li>
+ * <li>Color and intensity of each light source</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LightingManager lighting = new LightingManager();
+ *
+ * // Add light sources
+ * lighting.addLight(new LightSource(new Point3D(100, -50, 200), Color.YELLOW));
+ * lighting.addLight(new LightSource(new Point3D(-100, 50, 200), Color.BLUE));
+ *
+ * // Set ambient light (base illumination)
+ * lighting.setAmbientLight(new Color(30, 30, 30));
+ *
+ * // Calculate shaded color for a polygon (reusing result Color to avoid allocation)
+ * Color result = new Color();
+ * lighting.computeLighting(polygonCenter, surfaceNormal, baseColor, result);
+ * }</pre>
+ *
+ * @see LightSource represents a single light source
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ */
+public class LightingManager {
+
+    private final List<LightSource> lights = new ArrayList<>();
+    private Color ambientLight = new Color(10, 10, 10);
+
+    /**
+     * Optional global-illumination provider. When set, the polygon-aware
+     * {@link #computeLighting(SolidPolygon, Point3D, Point3D, Color, Color)}
+     * overload adds shadow tests and indirect light. Null by default:
+     * lighting behaves exactly as before (no occlusion, no indirect).
+     */
+    private volatile GiLightProvider giProvider;
+
+    /**
+     * Creates a new lighting manager with no light sources.
+     */
+    public LightingManager() {
+    }
+
+    /**
+     * Adds a light source to the scene.
+     *
+     * @param light the light source to add
+     */
+    public void addLight(final LightSource light) {
+        lights.add(light);
+    }
+
+    /**
+     * Computes lighting for a polygon and stores the result in an existing Color.
+     *
+     * <p>This method avoids allocation by reusing an existing Color instance.
+     * Safe to call from multiple threads on the same result Color - the computation
+     * is deterministic (same polygon, same lights = same result).</p>
+     *
+     * @param polygonCenter the center point of the polygon in world space
+     * @param normal        the surface normal vector (should be normalized)
+     * @param baseColor     the original color of the polygon
+     * @param result        the Color to receive the shaded result (modified in place)
+     */
+    public void computeLighting(final Point3D polygonCenter,
+                                final Point3D normal,
+                                final Color baseColor,
+                                final Color result) {
+        // Start with ambient light contribution
+        int totalR = ambientLight.r;
+        int totalG = ambientLight.g;
+        int totalB = ambientLight.b;
+
+        // Calculate contribution from each light source
+        for (final LightSource light : lights) {
+            final Point3D lightPos = light.getPosition();
+            final Color lightColor = light.getColor();
+            final double lightIntensity = light.getIntensity();
+
+            // Calculate vector from polygon to light
+            final double lightDirX = lightPos.x - polygonCenter.x;
+            final double lightDirY = lightPos.y - polygonCenter.y;
+            final double lightDirZ = lightPos.z - polygonCenter.z;
+
+            // Normalize the light direction
+            final double lightDist = Math.sqrt(
+                    lightDirX * lightDirX +
+                            lightDirY * lightDirY +
+                            lightDirZ * lightDirZ
+            );
+
+            if (lightDist < 0.0001)
+                continue;
+
+            final double invLightDist = 1.0 / lightDist;
+            final double normLightDirX = lightDirX * invLightDist;
+            final double normLightDirY = lightDirY * invLightDist;
+            final double normLightDirZ = lightDirZ * invLightDist;
+
+            // Calculate dot product (Lambert cosine law)
+            final double dotProduct = normal.x * normLightDirX +
+                    normal.y * normLightDirY +
+                    normal.z * normLightDirZ;
+
+            // Only add light if surface faces the light
+            if (dotProduct > 0) {
+                // Apply distance attenuation (inverse square law, simplified)
+                final double attenuation = 1.0 / (1.0 + 0.0001 * lightDist * lightDist);
+                final double intensity = dotProduct * attenuation * lightIntensity;
+
+                // Add light color contribution
+                totalR += (int) (lightColor.r * intensity);
+                totalG += (int) (lightColor.g * intensity);
+                totalB += (int) (lightColor.b * intensity);
+            }
+        }
+
+        // Clamp values to valid range and apply to base color
+        final int r = Math.min(255, (totalR * baseColor.r) / 255);
+        final int g = Math.min(255, (totalG * baseColor.g) / 255);
+        final int b = Math.min(255, (totalB * baseColor.b) / 255);
+
+        result.set(r, g, b, baseColor.a);
+    }
+
+    /**
+     * GI-aware lighting computation: identical to
+     * {@link #computeLighting(Point3D, Point3D, Color, Color)} when no
+     * {@link GiLightProvider} is installed. With a provider, lights occluded
+     * from the polygon are skipped (direct shadows) and the provider's
+     * indirect contribution is added on top.
+     *
+     * @param polygon       the polygon being shaded (provider key)
+     * @param polygonCenter the center point of the polygon in world space
+     * @param normal        the surface normal vector (should be normalized)
+     * @param baseColor     the original color of the polygon
+     * @param result        the Color to receive the shaded result (modified in place)
+     */
+    public void computeLighting(final SolidPolygon polygon,
+                                final Point3D polygonCenter,
+                                final Point3D normal,
+                                final Color baseColor,
+                                final Color result) {
+        final GiLightProvider gi = giProvider;
+        if (gi == null) {
+            computeLighting(polygonCenter, normal, baseColor, result);
+            return;
+        }
+
+        int totalR = ambientLight.r;
+        int totalG = ambientLight.g;
+        int totalB = ambientLight.b;
+
+        for (final LightSource light : lights) {
+            if (!gi.isLightVisible(polygon, light))
+                continue;
+
+            final Point3D lightPos = light.getPosition();
+            final Color lightColor = light.getColor();
+            final double lightIntensity = light.getIntensity();
+
+            final double lightDirX = lightPos.x - polygonCenter.x;
+            final double lightDirY = lightPos.y - polygonCenter.y;
+            final double lightDirZ = lightPos.z - polygonCenter.z;
+
+            final double lightDist = Math.sqrt(
+                    lightDirX * lightDirX +
+                            lightDirY * lightDirY +
+                            lightDirZ * lightDirZ
+            );
+
+            if (lightDist < 0.0001)
+                continue;
+
+            final double invLightDist = 1.0 / lightDist;
+            final double dotProduct = normal.x * lightDirX * invLightDist +
+                    normal.y * lightDirY * invLightDist +
+                    normal.z * lightDirZ * invLightDist;
+
+            if (dotProduct > 0) {
+                final double attenuation = 1.0 / (1.0 + 0.0001 * lightDist * lightDist);
+                final double intensity = dotProduct * attenuation * lightIntensity;
+
+                totalR += (int) (lightColor.r * intensity);
+                totalG += (int) (lightColor.g * intensity);
+                totalB += (int) (lightColor.b * intensity);
+            }
+        }
+
+        final int r = Math.min(255, (totalR * baseColor.r) / 255);
+        final int g = Math.min(255, (totalG * baseColor.g) / 255);
+        final int b = Math.min(255, (totalB * baseColor.b) / 255);
+
+        result.set(r, g, b, baseColor.a);
+        gi.addIndirectLight(polygon, baseColor, result);
+    }
+
+    /**
+     * Installs (or clears, with null) the global-illumination provider used
+     * by the polygon-aware computeLighting overload.
+     *
+     * @param giProvider the provider, or null to disable GI
+     */
+    public void setGiProvider(final GiLightProvider giProvider) {
+        this.giProvider = giProvider;
+    }
+
+    /**
+     * Returns the installed global-illumination provider, or null.
+     *
+     * @return the GI provider or null
+     */
+    public GiLightProvider getGiProvider() {
+        return giProvider;
+    }
+
+    /**
+     * Returns the ambient light color.
+     *
+     * @return the ambient light color
+     */
+    public Color getAmbientLight() {
+        return ambientLight;
+    }
+
+    /**
+     * Sets the ambient light color for the scene.
+     *
+     * <p>Ambient light provides base illumination that affects all surfaces
+     * equally, regardless of their orientation.</p>
+     *
+     * @param ambientLight the ambient light color
+     */
+    public void setAmbientLight(final Color ambientLight) {
+        this.ambientLight = ambientLight;
+    }
+
+    /**
+     * Returns all light sources in the scene.
+     *
+     * @return list of light sources
+     */
+    public List<LightSource> getLights() {
+        return lights;
+    }
+
+    /**
+     * Removes a light source from the scene.
+     *
+     * @param light the light source to remove
+     */
+    public void removeLight(final LightSource light) {
+        lights.remove(light);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/package-info.java
new file mode 100644 (file)
index 0000000..ce0963f
--- /dev/null
@@ -0,0 +1,21 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Lighting system for flat-shaded polygon rendering.
+ *
+ * <p>This package implements a simple Lambertian lighting model for shading
+ * solid polygons based on their surface normals relative to light sources.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager} - Manages lights and calculates shading</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource} - Represents a point light source</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.lighting;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/package-info.java
new file mode 100755 (executable)
index 0000000..a7e5fee
--- /dev/null
@@ -0,0 +1,26 @@
+/**
+ * Rasterization-based real-time software renderer for the Aukio 3D engine.
+ *
+ * <p>This package provides a complete rasterization pipeline that renders 3D scenes
+ * to a 2D pixel buffer using traditional approaches:</p>
+ * <ul>
+ *   <li><b>Wireframe rendering</b> - lines and wireframe shapes</li>
+ *   <li><b>Solid polygon rendering</b> - filled polygons with flat shading</li>
+ *   <li><b>Textured polygon rendering</b> - polygons with texture mapping and mipmap support</li>
+ *   <li><b>Depth sorting</b> - back-to-front painter's algorithm using Z-index ordering</li>
+ * </ul>
+ *
+ * <p>Key classes in this package:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection} - root container for all 3D shapes in a scene</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator} - collects and depth-sorts shapes for rendering</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.Color} - RGBA color representation with predefined constants</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic basic shape primitives (lines, polygons)
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite composite shapes (boxes, grids, text)
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture texture and mipmap support
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java
new file mode 100644 (file)
index 0000000..70e466b
--- /dev/null
@@ -0,0 +1,643 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Base class for shapes defined by a list of vertex coordinates.
+ *
+ * <p>This is the foundation for all primitive renderable shapes such as lines,
+ * solid polygons, and textured polygons. Each shape has a list of vertices
+ * ({@link Vertex} objects) that define its geometry in 3D space.</p>
+ *
+ * <p>During each render frame, the {@link #transform} method projects all vertices
+ * from world space to screen space. If all vertices are visible (in front of the camera),
+ * the shape is queued in the {@link RenderAggregator} for depth-sorted painting via
+ * the {@link #paint} method.</p>
+ *
+ * <p><b>Creating a custom coordinate shape:</b></p>
+ * <pre>{@code
+ * public class Triangle extends AbstractCoordinateShape {
+ *     private final Color color;
+ *
+ *     public Triangle(Point3D p1, Point3D p2, Point3D p3, Color color) {
+ *         super(new Vertex(p1), new Vertex(p2), new Vertex(p3));
+ *         this.color = color;
+ *     }
+ *
+ *     public void paint(RenderingContext ctx) {
+ *         // Custom painting logic using ctx.graphics and
+ *         // vertices.get(i).transformedCoordinate for screen positions
+ *     }
+ * }
+ * }</pre>
+ *
+ * @see AbstractShape the parent class for all shapes
+ * @see Vertex wraps a 3D coordinate with its transformed (screen-space) position
+ * @see RenderAggregator collects and depth-sorts shapes before painting
+ */
+public abstract class AbstractCoordinateShape extends AbstractShape {
+
+    /**
+     * Global counter used to assign unique IDs to shapes, ensuring deterministic
+     * rendering order for shapes at the same depth.
+     */
+    private static final AtomicInteger lastShapeId = new AtomicInteger();
+
+    /**
+     * Unique identifier for this shape instance, used as a tiebreaker when
+     * sorting shapes with identical Z-depth values.
+     */
+    public final int shapeId;
+
+    /**
+     * The vertex coordinates that define this shape's geometry.
+     * Each vertex contains both the original world-space coordinate and
+     * a transformed screen-space coordinate computed during {@link #transform}.
+     *
+     * <p>Stored as a mutable list to support CSG operations that modify
+     * polygon vertices in place (splitting, flipping).</p>
+     */
+    public final List<Vertex> vertices;
+
+    /**
+     * Average Z-depth of this shape in screen space after transformation,
+     * per buffer slot. Used by the {@link RenderAggregator} to sort shapes
+     * back-to-front for correct painter's algorithm rendering.
+     * Access via {@link #getZ(RenderingContext)} / {@link #getZ(int)}.
+     */
+    private double onScreenZ0;
+    private double onScreenZ1;
+    private double onScreenZ2;
+
+    /**
+     * Screen-space Y bounds of this shape after transformation, per buffer
+     * slot, expanded by {@link #getScreenYMargin(RenderingContext)} so they
+     * cover every pixel {@link #paint} can touch. Valid only in frames where
+     * this shape was queued for rendering. Used by {@link RenderAggregator}
+     * to bin shapes per paint tile by overlap.
+     */
+    private double onScreenMinY0;
+    private double onScreenMaxY0;
+    private double onScreenMinY1;
+    private double onScreenMaxY1;
+    private double onScreenMinY2;
+    private double onScreenMaxY2;
+
+    /**
+     * Screen-space X bounds of this shape after transformation, per buffer
+     * slot, expanded by {@link #getScreenXMargin(RenderingContext)}.
+     */
+    private double onScreenMinX0;
+    private double onScreenMaxX0;
+    private double onScreenMinX1;
+    private double onScreenMaxX1;
+    private double onScreenMinX2;
+    private double onScreenMaxX2;
+
+    /**
+     * Writes this shape's screen state for one buffer slot in one call.
+     * Used by bulk transform paths ({@code TriangleMeshBlock}) whose
+     * triangles carry no per-vertex objects: the handle exposes the same
+     * per-slot values an object-backed transform would have written, so
+     * the Z comparator and tile binning keep reading plain fields.
+     *
+     * @param slot buffer slot (0, 1 or 2)
+     * @param z    average camera-space Z
+     * @param minY screen-space minimum Y (with paint margins)
+     * @param maxY screen-space maximum Y
+     * @param minX screen-space minimum X (with paint margins)
+     * @param maxX screen-space maximum X
+     */
+    protected final void setSlotScreenState(final int slot, final double z,
+                                            final double minY, final double maxY,
+                                            final double minX, final double maxX) {
+        if (slot == 0) {
+            onScreenZ0 = z;
+            onScreenMinY0 = minY;
+            onScreenMaxY0 = maxY;
+            onScreenMinX0 = minX;
+            onScreenMaxX0 = maxX;
+        } else if (slot == 1) {
+            onScreenZ1 = z;
+            onScreenMinY1 = minY;
+            onScreenMaxY1 = maxY;
+            onScreenMinX1 = minX;
+            onScreenMaxX1 = maxX;
+        } else {
+            onScreenZ2 = z;
+            onScreenMinY2 = minY;
+            onScreenMaxY2 = maxY;
+            onScreenMinX2 = minX;
+            onScreenMaxX2 = maxX;
+        }
+    }
+
+    /**
+     * Near-plane-clipped vertex loop for this shape, per buffer slot.
+     * Null when the shape was NOT clipped this frame (all original vertices
+     * in front of the near plane) or when the shape was culled. When set,
+     * {@link #paint} must iterate THIS list instead of {@link #vertices}:
+     * the original vertices contain behind-camera positions whose projected
+     * screen coordinates are garbage (divide by z &lt;= 0 flips signs).
+     *
+     * <p>The list holds a mix of original {@link Vertex} objects (their
+     * per-slot state was filled by the normal transform) and freshly
+     * created intersection vertices (filled via
+     * {@link Vertex#setCameraSpaceCoordinate}). Stored per slot so the
+     * double-buffered pipeline can transform frame N+1 into slot B while
+     * frame N is still painting from slot A.</p>
+     */
+    private List<Vertex> clippedVertices0;
+
+    /**
+     * Cached subpixel-cull verdict (see the size check in
+     * {@link #transform}): while {@link #subpixelCulledEpoch} matches the
+     * context's epoch, transform returns immediately without any setup.
+     */
+    private boolean subpixelCulled;
+    private int subpixelCulledEpoch = -1;
+    private List<Vertex> clippedVertices1;
+    private List<Vertex> clippedVertices2;
+
+    /**
+     * Creates a shape with the specified number of vertices, each initialized
+     * to the origin (0, 0, 0).
+     *
+     * @param vertexCount the number of vertices in this shape
+     */
+    public AbstractCoordinateShape(final int vertexCount) {
+        vertices = new ArrayList<>(vertexCount);
+        for (int i = 0; i < vertexCount; i++) {
+            vertices.add(new Vertex());
+        }
+        shapeId = lastShapeId.getAndIncrement();
+    }
+
+    /**
+     * Creates a shape from the given vertices.
+     *
+     * @param vertices the vertices defining this shape's geometry
+     */
+    public AbstractCoordinateShape(final Vertex... vertices) {
+        this.vertices = new ArrayList<>(Arrays.asList(vertices));
+        shapeId = lastShapeId.getAndIncrement();
+    }
+
+    /**
+     * Creates a shape from a list of vertices.
+     *
+     * @param vertices the list of vertices defining this shape's geometry
+     */
+    public AbstractCoordinateShape(final List<Vertex> vertices) {
+        this.vertices = vertices;
+        shapeId = lastShapeId.getAndIncrement();
+    }
+
+    /**
+     * Returns the average Z-depth of this shape in screen space for the
+     * context's buffer slot.
+     *
+     * @param renderingContext the rendering context (selects the buffer slot)
+     * @return the average Z-depth value, used for depth sorting
+     */
+    public double getZ(final RenderingContext renderingContext) {
+        final int slot = renderingContext.vertexSlot;
+        return slot == 0 ? onScreenZ0 : slot == 1 ? onScreenZ1 : onScreenZ2;
+    }
+
+    /**
+     * Returns the average Z-depth of this shape for an explicit buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @return the average Z-depth value, used for depth sorting
+     */
+    public double getZ(final int slot) {
+        return slot == 0 ? onScreenZ0 : slot == 1 ? onScreenZ1 : onScreenZ2;
+    }
+
+    /**
+     * Screen-space minimum Y bound (pixels) for the given buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @return minimum Y this shape's paint can touch
+     */
+    public double onScreenMinY(final int slot) {
+        return slot == 0 ? onScreenMinY0 : slot == 1 ? onScreenMinY1 : onScreenMinY2;
+    }
+
+    /**
+     * Screen-space maximum Y bound (pixels) for the given buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @return maximum Y this shape's paint can touch
+     */
+    public double onScreenMaxY(final int slot) {
+        return slot == 0 ? onScreenMaxY0 : slot == 1 ? onScreenMaxY1 : onScreenMaxY2;
+    }
+
+    /**
+     * Screen-space minimum X bound (pixels) for the given buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @return minimum X this shape's paint can touch
+     */
+    public double onScreenMinX(final int slot) {
+        return slot == 0 ? onScreenMinX0 : slot == 1 ? onScreenMinX1 : onScreenMinX2;
+    }
+
+    /**
+     * Screen-space maximum X bound (pixels) for the given buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @return maximum X this shape's paint can touch
+     */
+    public double onScreenMaxX(final int slot) {
+        return slot == 0 ? onScreenMaxX0 : slot == 1 ? onScreenMaxX1 : onScreenMaxX2;
+    }
+
+    /**
+     * Sets the average Z-depth for the given buffer slot.
+     *
+     * @param slot buffer slot (0 or 1)
+     * @param z    average Z-depth value
+     */
+    public void setZ(final int slot, final double z) {
+        if (slot == 0)
+            onScreenZ0 = z;
+        else if (slot == 1)
+            onScreenZ1 = z;
+        else
+            onScreenZ2 = z;
+    }
+
+    /**
+     * Returns the axis-aligned bounding box computed from vertex coordinates.
+     *
+     * <p>The bounding box encompasses all vertices in this shape, computed
+     * by finding the minimum and maximum coordinates along each axis.</p>
+     *
+     * <p><b>Caching:</b> The bounding box is cached after first computation.
+     * If vertices change, call {@link #invalidateBounds()} before calling
+     * this method to trigger recomputation.</p>
+     *
+     * @return the axis-aligned bounding box in local coordinates
+     */
+    @Override
+    public Box getBoundingBox() {
+        if (cachedBoundingBox == null && !vertices.isEmpty()) {
+            // Compute bounds from vertex coordinates
+            double minX = Double.MAX_VALUE;
+            double maxX = -Double.MAX_VALUE;
+            double minY = Double.MAX_VALUE;
+            double maxY = -Double.MAX_VALUE;
+            double minZ = Double.MAX_VALUE;
+            double maxZ = -Double.MAX_VALUE;
+
+            for (final Vertex vertex : vertices) {
+                final Point3D coord = vertex.coordinate;
+                minX = Math.min(minX, coord.x);
+                maxX = Math.max(maxX, coord.x);
+                minY = Math.min(minY, coord.y);
+                maxY = Math.max(maxY, coord.y);
+                minZ = Math.min(minZ, coord.z);
+                maxZ = Math.max(maxZ, coord.z);
+            }
+
+            cachedBoundingBox = new Box(
+                    new Point3D(minX, minY, minZ),
+                    new Point3D(maxX, maxY, maxZ)
+            );
+        }
+        return cachedBoundingBox != null ? cachedBoundingBox : super.getBoundingBox();
+    }
+
+    /**
+     * Translates all vertices by the specified offsets.
+     *
+     * <p>This method moves the entire shape by modifying each vertex's
+     * world-space coordinate. It also invalidates the cached bounding box
+     * so that frustum culling uses the correct bounds after movement.</p>
+     *
+     * <p><b>Usage example:</b></p>
+     * <pre>{@code
+     * // Move shape 10 units up (Y decreases in Aukio 3D's coordinate system)
+     * shape.translate(0, -10, 0);
+     *
+     * // Move shape diagonally
+     * shape.translate(5, 0, 5);
+     * }</pre>
+     *
+     * @param dx offset along the X axis (positive = right)
+     * @param dy offset along the Y axis (positive = down, negative = up)
+     * @param dz offset along the Z axis (positive = away from camera)
+     */
+    public void translate(final double dx, final double dy, final double dz) {
+        for (final Vertex vertex : vertices) {
+            vertex.coordinate.x += dx;
+            vertex.coordinate.y += dy;
+            vertex.coordinate.z += dz;
+        }
+        invalidateBounds();
+    }
+
+    /**
+     * Paints this shape onto the rendering context's pixel buffer.
+     *
+     * <p>This method is called after all shapes have been transformed and sorted
+     * by depth. Implementations should use the transformed screen-space coordinates
+     * from {@link Vertex#transformedCoordinate} to draw pixels.</p>
+     *
+     * @param renderBuffer the rendering context containing the pixel buffer and graphics context
+     */
+    public abstract void paint(RenderingContext renderBuffer);
+
+    /**
+     * Extra screen-space Y distance beyond the vertex bounds that
+     * {@link #paint} can touch. Shapes whose paint output extends past the
+     * vertex positions (thick lines, billboards, text glyphs) must override
+     * this so tile binning does not drop them from tiles they
+     * partially overlap.
+     *
+     * @param renderingContext the rendering context (provides projection
+     *                         parameters for size computation)
+     * @return the Y margin in screen pixels (0 = vertex bounds are exact)
+     */
+    protected double getScreenYMargin(final RenderingContext renderingContext) {
+        return 0;
+    }
+
+    /**
+     * Extra screen-space X distance beyond the vertex bounds that
+     * {@link #paint} can touch. Same contract as
+     * {@link #getScreenYMargin(RenderingContext)}, for the X axis.
+     *
+     * @param renderingContext the rendering context (provides projection
+     *                         parameters for size computation)
+     * @return the X margin in screen pixels (0 = vertex bounds are exact)
+     */
+    protected double getScreenXMargin(final RenderingContext renderingContext) {
+        return 0;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * <p>Transforms all vertices to screen space by applying the current transform stack.
+     * If ALL vertices are behind the near plane the shape is culled; if SOME are,
+     * the vertex loop is clipped against the near plane (see
+     * {@link #clipToNearPlane(RenderingContext)}) and the clipped loop — not the
+     * original vertices — is queued for rendering. Computes the average Z-depth
+     * and screen bounds from the active (possibly clipped) vertices and queues
+     * this shape for rendering.</p>
+     */
+    @Override
+    public void transform(final TransformStack transforms,
+                          final RenderAggregator aggregator,
+                          final RenderingContext renderingContext) {
+
+        // Cached subpixel-cull verdict: skip the entire setup (vertex
+        // transforms, clipping, bounds) while the verdict is fresh. The
+        // epoch advances on significant camera movement, so a culled
+        // shape is re-evaluated whenever it could have grown on screen.
+        if (subpixelCulled
+                && subpixelCulledEpoch == renderingContext.subpixelCullingEpoch)
+            return;
+
+        final int slot = renderingContext.vertexSlot;
+        final double near = renderingContext.nearPlaneDistance;
+        boolean anyBehind = false;
+        boolean allBehind = true;
+
+        // Indexed loops, not enhanced-for: an iterator per shape per
+        // frame was a measurable share of render-time allocation
+        // (~26 MB sampled over 8 frames at the FO4 spawn view).
+        for (int vi = 0; vi < vertices.size(); vi++) {
+            final Vertex geometryPoint = vertices.get(vi);
+            geometryPoint.calculateLocationRelativeToViewer(transforms, renderingContext);
+            if (geometryPoint.transformedCoordinate(renderingContext).z > near)
+                allBehind = false;
+            else
+                anyBehind = true;
+        }
+
+        if (allBehind) {
+            setClippedVertices(slot, null);
+            return;
+        }
+
+        final List<Vertex> active;
+        if (anyBehind) {
+            active = clipToNearPlane(renderingContext);
+            // Degenerate sliver (fewer points than a renderable primitive):
+            // a line needs 2, a polygon needs 3.
+            if (active.size() < Math.min(vertices.size(), 3)) {
+                setClippedVertices(slot, null);
+                return;
+            }
+            setClippedVertices(slot, active);
+        } else {
+            setClippedVertices(slot, null);
+            active = vertices;
+        }
+
+        double accumulatedZ = 0;
+        double minY = Double.POSITIVE_INFINITY;
+        double maxY = Double.NEGATIVE_INFINITY;
+        double minX = Double.POSITIVE_INFINITY;
+        double maxX = Double.NEGATIVE_INFINITY;
+
+        for (int vi = 0; vi < active.size(); vi++) {
+            final Vertex geometryPoint = active.get(vi);
+
+            final Point3D transformed = geometryPoint.transformedCoordinate(renderingContext);
+            final Point2D onScreen = geometryPoint.onScreenCoordinate(renderingContext);
+
+            accumulatedZ += transformed.z;
+
+            if (onScreen.y < minY)
+                minY = onScreen.y;
+            if (onScreen.y > maxY)
+                maxY = onScreen.y;
+            if (onScreen.x < minX)
+                minX = onScreen.x;
+            if (onScreen.x > maxX)
+                maxX = onScreen.x;
+        }
+
+        // Subpixel culling: raw projected span (before paint margins)
+        // below the threshold on both axes means the shape cannot cover
+        // even a fraction of one pixel. Cache the verdict so frames with
+        // a barely-moved camera skip this shape's whole transform setup.
+        final double cullThreshold = renderingContext.subpixelCullingThreshold;
+        if (cullThreshold > 0
+                && maxX - minX < cullThreshold
+                && maxY - minY < cullThreshold) {
+            subpixelCulled = true;
+            subpixelCulledEpoch = renderingContext.subpixelCullingEpoch;
+            return;
+        }
+        subpixelCulled = false;
+
+        final double z = accumulatedZ / active.size();
+        final double marginY = getScreenYMargin(renderingContext);
+        final double loY = minY - marginY;
+        final double hiY = maxY + marginY;
+        final double marginX = getScreenXMargin(renderingContext);
+        final double loX = minX - marginX;
+        final double hiX = maxX + marginX;
+        if (slot == 0) {
+            onScreenZ0 = z;
+            onScreenMinY0 = loY;
+            onScreenMaxY0 = hiY;
+            onScreenMinX0 = loX;
+            onScreenMaxX0 = hiX;
+        } else if (slot == 1) {
+            onScreenZ1 = z;
+            onScreenMinY1 = loY;
+            onScreenMaxY1 = hiY;
+            onScreenMinX1 = loX;
+            onScreenMaxX1 = hiX;
+        } else {
+            onScreenZ2 = z;
+            onScreenMinY2 = loY;
+            onScreenMaxY2 = hiY;
+            onScreenMinX2 = loX;
+            onScreenMaxX2 = hiX;
+        }
+        // Screen-space culling: composite frustum culling skips whole
+        // invisible cells, but a visible cell still queues every one of
+        // its triangles — including those behind the camera or past the
+        // viewport edge. Bounds (with paint margins) are already computed
+        // above, so drop shapes that cannot touch the viewport: on dense
+        // scenes this shrinks the sort/paint queues several-fold. The
+        // test mirrors RenderAggregator tile binning, which uses the same
+        // margined bounds, so nothing paintable is ever dropped.
+        if (hiX < renderingContext.renderMinX
+                || loX >= renderingContext.renderMaxX
+                || hiY < renderingContext.renderMinY
+                || loY >= renderingContext.renderMaxY) {
+            return;
+        }
+        aggregator.queueShapeForRendering(this);
+    }
+
+    /**
+     * Returns the near-plane-clipped vertex loop for the context's buffer
+     * slot, or null if this shape was not clipped (or was culled) this
+     * frame. Paint implementations must use this list when non-null.
+     *
+     * @param renderingContext the rendering context (selects the buffer slot)
+     * @return the clipped vertex loop, or null
+     */
+    public List<Vertex> clippedVertices(final RenderingContext renderingContext) {
+        final int slot = renderingContext.vertexSlot;
+        return slot == 0 ? clippedVertices0
+                : slot == 1 ? clippedVertices1 : clippedVertices2;
+    }
+
+    /**
+     * Stores the clipped vertex loop for the given buffer slot.
+     *
+     * @param slot    buffer slot (0, 1 or 2)
+     * @param clipped the clipped loop, or null to clear
+     */
+    private void setClippedVertices(final int slot, final List<Vertex> clipped) {
+        if (slot == 0)
+            clippedVertices0 = clipped;
+        else if (slot == 1)
+            clippedVertices1 = clipped;
+        else
+            clippedVertices2 = clipped;
+    }
+
+    /**
+     * Clips this shape's vertex loop against the near plane
+     * (camera-space z == {@code renderingContext.nearPlaneDistance}),
+     * Sutherland-Hodgman style. Assumes at least one vertex is in front and
+     * at least one is behind (checked by {@link #transform}).
+     *
+     * <p>For every edge crossing the plane a new intersection vertex is
+     * created with linearly interpolated position, UV and normal, and its
+     * per-slot screen position is computed immediately via
+     * {@link Vertex#setCameraSpaceCoordinate}. In-front original vertices
+     * pass through by reference.</p>
+     *
+     * <p>A convex N-gon crossing the plane clips to a single contiguous
+     * loop of 3..N+1 vertices (a triangle can become a quad). Two-vertex
+     * shapes (lines) are clipped as a single open edge, yielding the
+     * in-front endpoint plus the intersection point.</p>
+     *
+     * @param renderingContext the rendering context (provides the near
+     *                         distance and projection parameters)
+     * @return the clipped vertex loop in original winding order
+     */
+    private List<Vertex> clipToNearPlane(final RenderingContext renderingContext) {
+        final double near = renderingContext.nearPlaneDistance;
+        final int n = vertices.size();
+        // Lines (2 vertices) form one open edge, not a closed loop:
+        // iterating n edges would emit the intersection point twice.
+        final int edgeCount = n == 2 ? 1 : n;
+        final List<Vertex> result = new ArrayList<>(n + 1);
+
+        for (int i = 0; i < edgeCount; i++) {
+            final Vertex current = vertices.get(i);
+            final Vertex next = vertices.get((i + 1) % n);
+            final Point3D c = current.transformedCoordinate(renderingContext);
+            final Point3D p = next.transformedCoordinate(renderingContext);
+            final boolean currentIn = c.z > near;
+            final boolean nextIn = p.z > near;
+
+            if (currentIn)
+                result.add(current);
+
+            if (currentIn != nextIn) {
+                final double t = (near - c.z) / (p.z - c.z);
+                result.add(interpolateAtPlane(current, next, c, p, t, renderingContext));
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Creates the vertex where edge a-&gt;b crosses the near plane.
+     * Position, texture coordinate and normal are interpolated with the
+     * same parameter t (linear in 3D, which is exactly what
+     * perspective-correct texturing expects of a point on the edge).
+     */
+    private static Vertex interpolateAtPlane(final Vertex a, final Vertex b,
+                                             final Point3D ca, final Point3D cb,
+                                             final double t,
+                                             final RenderingContext renderingContext) {
+        final double x = ca.x + (cb.x - ca.x) * t;
+        final double y = ca.y + (cb.y - ca.y) * t;
+        final double z = ca.z + (cb.z - ca.z) * t;
+
+        final Point2D uv = (a.textureCoordinate != null && b.textureCoordinate != null)
+                ? new Point2D(
+                a.textureCoordinate.x + (b.textureCoordinate.x - a.textureCoordinate.x) * t,
+                a.textureCoordinate.y + (b.textureCoordinate.y - a.textureCoordinate.y) * t)
+                : null;
+
+        final Vertex clipped = new Vertex(new Point3D(x, y, z), uv);
+        if (a.normal != null && b.normal != null)
+            clipped.normal = a.normal.interpolate(b.normal, t);
+        clipped.setCameraSpaceCoordinate(x, y, z, renderingContext);
+        return clipped;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractShape.java
new file mode 100644 (file)
index 0000000..f6e81a3
--- /dev/null
@@ -0,0 +1,157 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+
+/**
+ * Base class for all renderable shapes in the Aukio 3D engine.
+ *
+ * <p>Every shape that can be rendered must extend this class and implement the
+ * {@link #transform(TransformStack, RenderAggregator, RenderingContext)} method,
+ * which projects the shape from world space into screen space during each render frame.</p>
+ *
+ * <p>Shapes can optionally have a {@link MouseInteractionController} attached to receive
+ * mouse click and hover events when the user interacts with the shape in the 3D view.</p>
+ *
+ * <p><b>Shape hierarchy overview:</b></p>
+ * <pre>
+ * AbstractShape
+ *   +-- AbstractCoordinateShape   (shapes with vertex coordinates: lines, polygons)
+ *   +-- AbstractCompositeShape    (groups of sub-shapes: boxes, grids, text canvases)
+ * </pre>
+ *
+ * @see AbstractCoordinateShape for shapes defined by vertex coordinates
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape for compound shapes
+ * @see MouseInteractionController for handling mouse events on shapes
+ */
+public abstract class AbstractShape {
+
+    /**
+     * Default constructor for abstract shape.
+     */
+    public AbstractShape() {
+    }
+
+    /**
+     * Optional controller that receives mouse interaction events (click, enter, exit)
+     * when the user interacts with this shape in the 3D view.
+     * Set to {@code null} if mouse interaction is not needed.
+     */
+    public MouseInteractionController mouseInteractionController;
+
+    /**
+     * Cached bounding box in local coordinates.
+     * Lazily computed on first call to {@link #getBoundingBox()}.
+     * Subclasses should set this to null when geometry changes to trigger recomputation.
+     */
+    protected Box cachedBoundingBox = null;
+
+    /**
+     * Returns the axis-aligned bounding box for this shape in local coordinates.
+     *
+     * <p>The bounding box is used for frustum culling to determine if the shape
+     * is potentially visible before expensive vertex transformations.</p>
+     *
+     * <p><b>Conservative default:</b> Returns a very large box that ensures
+     * the shape is always considered visible. Subclasses should override to
+     * provide tight bounds computed from their geometry.</p>
+     *
+     * <p><b>Caching:</b> The bounding box is cached after first computation.
+     * If geometry changes, call {@link #invalidateBounds()} to trigger
+     * recomputation on next call.</p>
+     *
+     * @return the axis-aligned bounding box in local coordinates
+     */
+    public Box getBoundingBox() {
+        if (cachedBoundingBox == null) {
+            // Conservative default: very large box (shape always visible)
+            cachedBoundingBox = new Box(
+                    new Point3D(-1e10, -1e10, -1e10),
+                    new Point3D(1e10, 1e10, 1e10)
+            );
+        }
+        return cachedBoundingBox;
+    }
+
+    /**
+     * Invalidates the cached bounding box, forcing recomputation on next call
+     * to {@link #getBoundingBox()}.
+     *
+     * <p>Call this method whenever the shape's geometry changes to ensure
+     * frustum culling uses up-to-date bounds. This is critical for shapes
+     * that move or deform after creation.</p>
+     *
+     * <p><b>Usage example:</b></p>
+     * <pre>{@code
+     * // After modifying vertex coordinates directly:
+     * vertex.coordinate.translate(0, 10, 0);
+     * shape.invalidateBounds();
+     *
+     * // Or use translate() on AbstractCoordinateShape which handles this automatically
+     * }</pre>
+     */
+    public void invalidateBounds() {
+        cachedBoundingBox = null;
+    }
+
+    /**
+     * Assigns a mouse interaction controller to this shape.
+     *
+     * <p>Example usage:</p>
+     * <pre>{@code
+     * shape.setMouseInteractionController(new MouseInteractionController() {
+     *     public boolean mouseClicked(int button) {
+     *         System.out.println("Shape clicked!");
+     *         return true;
+     *     }
+     *     public boolean mouseEntered() { return false; }
+     *     public boolean mouseExited() { return false; }
+     * });
+     * }</pre>
+     *
+     * @param mouseInteractionController the controller to handle mouse events,
+     *                                    or {@code null} to disable mouse interaction
+     */
+    public void setMouseInteractionController(
+            final MouseInteractionController mouseInteractionController) {
+        this.mouseInteractionController = mouseInteractionController;
+    }
+
+    /**
+     * Transforms this shape from world space to screen space and queues it for rendering.
+     *
+     * <p>This method is called once per frame for each shape in the scene. Implementations
+     * should apply the current transform stack to their vertices, compute screen-space
+     * coordinates, and if the shape is visible, add it to the {@link RenderAggregator}
+     * for depth-sorted painting.</p>
+     *
+     * @param transforms       the current stack of transforms (world-to-camera transformations)
+     * @param aggregator       collects transformed shapes for depth-sorted rendering
+     * @param renderingContext  provides frame dimensions, graphics context, and frame metadata
+     */
+    public abstract void transform(final TransformStack transforms,
+                                   final RenderAggregator aggregator,
+                                   final RenderingContext renderingContext);
+
+    /**
+     * Estimated cost of transforming this shape, in arbitrary units where
+     * a leaf primitive counts 1. Composites override this with their total
+     * subtree weight. Used by the parallel transform fork to decide where
+     * splitting pays off.
+     *
+     * @param renderingContext the rendering context (frame identity for caching)
+     * @return transform weight, always at least 1
+     */
+    public int getTransformWeight(final RenderingContext renderingContext) {
+        return 1;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/Billboard.java
new file mode 100644 (file)
index 0000000..1924793
--- /dev/null
@@ -0,0 +1,249 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap;
+
+/**
+ * A billboard: a texture that always faces the viewer.
+ *
+ * <p>This class implements the "billboard" rendering technique where the texture
+ * remains oriented towards the camera regardless of 3D position. The visible size
+ * is calculated based on distance from viewer (z-coordinate) and scale factor.</p>
+ *
+ * <p><b>Texture mapping algorithm:</b></p>
+ * <ol>
+ *   <li>Calculates screen coverage based on perspective</li>
+ *   <li>Clips to viewport boundaries</li>
+ *   <li>Maps texture pixels to screen pixels using proportional scaling</li>
+ * </ol>
+ *
+ * @see GlowingPoint a billboard with a circular gradient texture
+ * @see Texture
+ */
+public class Billboard extends AbstractCoordinateShape {
+
+    private static final double SCALE_MULTIPLIER = 0.005;
+
+    /**
+     * The texture to display on this billboard.
+     */
+    public final Texture texture;
+
+    /**
+     * Scale factor for the billboard's visible size.
+     * <ul>
+     *   <li>0 means infinitely small</li>
+     *   <li>1 is recommended to maintain texture sharpness</li>
+     * </ul>
+     */
+    private double scale;
+
+    /**
+     * Creates a billboard at the specified position with the given scale and texture.
+     *
+     * @param point   the 3D position of the billboard center
+     * @param scale   the scale factor (1.0 is recommended for sharpness)
+     * @param texture the texture to display
+     */
+    public Billboard(final Point3D point, final double scale,
+                     final Texture texture) {
+        super(new Vertex(point));
+        this.texture = texture;
+        setScale(scale);
+    }
+
+    /**
+     * The screen-aligned quad extends this far above and below the center
+     * vertex, well beyond the (single-vertex) Y range.
+     */
+    @Override
+    protected double getScreenYMargin(final RenderingContext renderingContext) {
+        return (renderingContext.width * scale * texture.primaryBitmap.height)
+                / vertices.get(0).transformedCoordinate(renderingContext).z;
+    }
+
+    /**
+     * Same story on the X axis: the quad extends half its screen width
+     * to either side of the center vertex.
+     */
+    @Override
+    protected double getScreenXMargin(final RenderingContext renderingContext) {
+        return (renderingContext.width * scale * texture.primaryBitmap.width)
+                / vertices.get(0).transformedCoordinate(renderingContext).z;
+    }
+
+    /**
+     * Renders this billboard to the screen.
+     *
+     * <p>The billboard is rendered as a screen-aligned quad centered on the projected
+     * position. The size is computed based on distance and scale factor.</p>
+     *
+     * <p><b>Performance optimization:</b> Uses fixed-point incremental stepping to avoid
+     * per-pixel division, and inlines alpha blending to avoid method call overhead.
+     * This provides 50-70% better performance than the previous division-based approach.</p>
+     *
+     * @param targetRenderingArea the rendering context containing the pixel buffer
+     */
+    @Override
+    public void paint(final RenderingContext targetRenderingArea) {
+        // Sprites don't participate in depth (overlay semantics) —
+        // paint only in the alpha pass.
+        if (targetRenderingArea.depthPass == 1)
+            return;
+
+        // distance from camera/viewer to center of the texture
+        final double z = vertices.get(0).transformedCoordinate(targetRenderingArea).z;
+
+        // compute forward oriented texture visible distance from center
+        final double visibleHorizontalDistanceFromCenter = (targetRenderingArea.width
+                * scale * texture.primaryBitmap.width) / z;
+
+        final double visibleVerticalDistanceFromCenter = (targetRenderingArea.width
+                * scale * texture.primaryBitmap.height) / z;
+
+        // compute visible pixel density, and get appropriate bitmap
+        final double scale = (visibleHorizontalDistanceFromCenter * 2)
+                / texture.primaryBitmap.width;
+
+        final TextureBitmap textureBitmap = texture.getMipmapForScale(scale);
+
+        final Point2D onScreenCoordinate = vertices.get(0).onScreenCoordinate(targetRenderingArea);
+
+        // compute Y
+        final int onScreenUncappedYStart = (int) (onScreenCoordinate.y - visibleVerticalDistanceFromCenter);
+        final int onScreenUncappedYEnd = (int) (onScreenCoordinate.y + visibleVerticalDistanceFromCenter);
+        final int onScreenUncappedHeight = onScreenUncappedYEnd - onScreenUncappedYStart;
+
+        int onScreenCappedYStart = onScreenUncappedYStart;
+        int onScreenCappedYEnd = onScreenUncappedYEnd;
+
+        // cap Y to upper screen border
+        if (onScreenCappedYStart < 0)
+            onScreenCappedYStart = 0;
+
+        // cap Y to lower screen border
+        if (onScreenCappedYEnd > targetRenderingArea.height)
+            onScreenCappedYEnd = targetRenderingArea.height;
+
+        // clamp to render Y bounds
+        onScreenCappedYStart = Math.max(onScreenCappedYStart, targetRenderingArea.renderMinY);
+        onScreenCappedYEnd = Math.min(onScreenCappedYEnd, targetRenderingArea.renderMaxY);
+        if (onScreenCappedYStart >= onScreenCappedYEnd)
+            return;
+
+        // compute X
+        final int onScreenUncappedXStart = (int) (onScreenCoordinate.x - visibleHorizontalDistanceFromCenter);
+        final int onScreenUncappedXEnd = (int) (onScreenCoordinate.x + visibleHorizontalDistanceFromCenter);
+        final int onScreenUncappedWidth = onScreenUncappedXEnd - onScreenUncappedXStart;
+
+        // cap X to left viewport border (supports stereo per-eye clipping)
+        int onScreenCappedXStart = onScreenUncappedXStart;
+        if (onScreenCappedXStart < targetRenderingArea.renderMinX)
+            onScreenCappedXStart = targetRenderingArea.renderMinX;
+
+        // cap X to right viewport border (supports stereo per-eye clipping)
+        int onScreenCappedXEnd = onScreenUncappedXEnd;
+        if (onScreenCappedXEnd > targetRenderingArea.renderMaxX)
+            onScreenCappedXEnd = targetRenderingArea.renderMaxX;
+
+        if (onScreenCappedXStart >= onScreenCappedXEnd)
+            return;
+
+        final int[] targetPixels = targetRenderingArea.pixels;
+        final int[] sourcePixels = textureBitmap.pixels;
+        final int textureWidth = textureBitmap.width;
+        final int textureHeight = textureBitmap.height;
+        final int targetWidth = targetRenderingArea.width;
+
+        // Fixed-point (16.16) texture stepping values - eliminates per-pixel division
+        // Source X advances by textureWidth / onScreenUncappedWidth per screen pixel
+        final int sourceXStep = (textureWidth << 16) / onScreenUncappedWidth;
+        // Source Y advances by textureHeight / onScreenUncappedHeight per screen scanline
+        final int sourceYStep = (textureHeight << 16) / onScreenUncappedHeight;
+
+        // Initialize source Y position (fixed-point) at the first capped scanline
+        int sourceY = ((onScreenCappedYStart - onScreenUncappedYStart) * sourceYStep);
+
+        for (int y = onScreenCappedYStart; y < onScreenCappedYEnd; y++) {
+
+            // Convert fixed-point Y to integer scanline base address
+            final int sourceYInt = sourceY >> 16;
+            final int scanlineBase = sourceYInt * textureWidth;
+
+            // Initialize source X position (fixed-point) at the first capped pixel
+            int sourceX = ((onScreenCappedXStart - onScreenUncappedXStart) * sourceXStep);
+
+            int targetOffset = (y * targetWidth) + onScreenCappedXStart;
+
+            for (int x = onScreenCappedXStart; x < onScreenCappedXEnd; x++) {
+
+                // Convert fixed-point X to integer and compute source address
+                final int sourceAddress = scanlineBase + (sourceX >> 16);
+
+                // Inline alpha blending from TextureBitmap.drawPixel()
+                final int sourcePixel = sourcePixels[sourceAddress];
+                final int srcAlpha = (sourcePixel >> 24) & 0xff;
+
+                if (srcAlpha != 0) {
+                    if (srcAlpha == 255) {
+                        // Fully opaque - direct copy
+                        targetPixels[targetOffset] = sourcePixel;
+                    } else {
+                        // Semi-transparent - alpha blend
+                        final int backgroundAlpha = 255 - srcAlpha;
+
+                        final int srcR = ((sourcePixel >> 16) & 0xff) * srcAlpha;
+                        final int srcG = ((sourcePixel >> 8) & 0xff) * srcAlpha;
+                        final int srcB = (sourcePixel & 0xff) * srcAlpha;
+
+                        final int destPixel = targetPixels[targetOffset];
+                        final int destR = (destPixel >> 16) & 0xff;
+                        final int destG = (destPixel >> 8) & 0xff;
+                        final int destB = destPixel & 0xff;
+
+                        final int r = ((destR * backgroundAlpha) + srcR) >> 8;
+                        final int g = ((destG * backgroundAlpha) + srcG) >> 8;
+                        final int b = ((destB * backgroundAlpha) + srcB) >> 8;
+
+                        targetPixels[targetOffset] = (r << 16) | (g << 8) | b;
+                    }
+                }
+
+                // Advance source X using fixed-point addition (no division!)
+                sourceX += sourceXStep;
+                targetOffset++;
+            }
+
+            // Advance source Y using fixed-point addition (no division!)
+            sourceY += sourceYStep;
+        }
+    }
+
+    /**
+     * Sets the scale factor for this billboard.
+     *
+     * @param scale the scale factor (1.0 is recommended for sharpness)
+     */
+    public void setScale(final double scale) {
+        this.scale = scale * SCALE_MULTIPLIER;
+    }
+
+    /**
+     * Returns the 3D position of this billboard.
+     *
+     * @return the center position in world coordinates
+     */
+    public Point3D getLocation() {
+        return vertices.get(0).coordinate;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/GlowingPoint.java
new file mode 100644 (file)
index 0000000..3b30912
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.WeakHashMap;
+
+import static java.lang.Math.pow;
+import static java.lang.Math.sqrt;
+
+/**
+ * A glowing 3D point rendered with a circular gradient texture.
+ *
+ * <p>This class creates and reuses textures for glowing points of the same color.
+ * The texture is a circle with an alpha gradient from center to edge, ensuring
+ * a consistent visual appearance regardless of viewing angle.</p>
+ *
+ * <p><b>Texture sharing:</b> Glowing points of the same color share textures
+ * to reduce memory usage. Textures are garbage collected via WeakHashMap when
+ * no longer referenced.</p>
+ *
+ * @see Billboard the parent class
+ * @see Color
+ */
+public class GlowingPoint extends Billboard {
+
+    private static final int TEXTURE_RESOLUTION_PIXELS = 100;
+
+    /**
+     * Set of all existing glowing points, used for texture sharing.
+     */
+    private static final Set<GlowingPoint> glowingPoints = Collections.newSetFromMap(new WeakHashMap<>());
+    private final Color color;
+
+    /**
+     * Creates a glowing point at the specified position with the given size and color.
+     *
+     * @param point     the 3D position of the point
+     * @param pointSize the visible size of the point
+     * @param color     the color of the glow
+     */
+    public GlowingPoint(final Point3D point, final double pointSize,
+                        final Color color) {
+        super(point, computeScale(pointSize), getTexture(color));
+        this.color = color;
+
+        synchronized (glowingPoints) {
+            glowingPoints.add(this);
+        }
+    }
+
+
+    /**
+     * Computes the scale factor from point size.
+     *
+     * @param pointSize the desired visible size
+     * @return the scale factor for the billboard
+     */
+    private static double computeScale(double pointSize) {
+        return pointSize / ((double) (TEXTURE_RESOLUTION_PIXELS / 50f));
+    }
+
+    /**
+     * Returns a texture for a glowing point of the given color.
+     *
+     * <p>Attempts to reuse an existing texture from another glowing point of the
+     * same color. If none exists, creates a new texture.</p>
+     *
+     * @param color the color of the glow
+     * @return a texture with a circular alpha gradient
+     */
+    private static Texture getTexture(final Color color) {
+        // attempt to reuse texture from existing glowing point of the same color
+        synchronized (glowingPoints) {
+            for (GlowingPoint glowingPoint : glowingPoints)
+                if (color.equals(glowingPoint.color))
+                    return glowingPoint.texture;
+        }
+
+        // existing texture not found, creating new one
+        return createTexture(color);
+    }
+
+    /**
+     * Creates a texture for a glowing point of the given color.
+     * The texture is a circle with a gradient from transparent to the given color.
+     */
+    private static Texture createTexture(final Color color) {
+        final Texture texture = new Texture(TEXTURE_RESOLUTION_PIXELS, TEXTURE_RESOLUTION_PIXELS, 1);
+        int halfResolution = TEXTURE_RESOLUTION_PIXELS / 2;
+
+        for (int x = 0; x < TEXTURE_RESOLUTION_PIXELS; x++)
+            for (int y = 0; y < TEXTURE_RESOLUTION_PIXELS; y++) {
+                final int distanceFromCenter = (int) sqrt(pow(halfResolution - x, 2) + pow(halfResolution - y, 2));
+
+                int alpha = 255 - ((270 * distanceFromCenter) / halfResolution);
+                if (alpha < 0)
+                    alpha = 0;
+
+                texture.primaryBitmap.pixels[texture.primaryBitmap.getAddress(x, y)] =
+                    (alpha << 24) | (color.r << 16) | (color.g << 8) | color.b;
+            }
+
+        return texture;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/Line.java
new file mode 100644 (file)
index 0000000..d327c38
--- /dev/null
@@ -0,0 +1,465 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.util.List;
+
+
+/**
+ * A 3D line segment with perspective-correct width and alpha blending.
+ * <p>
+ * This class represents a line between two 3D points, rendered with a specified
+ * width that adjusts based on perspective (distance from the viewer).
+ * The line is drawn using interpolators to handle edge cases and alpha blending for
+ * transparency effects.
+ * <p>
+ * The rendering algorithm:
+ * 1. For thin lines (below a threshold), draws single-pixel lines with alpha
+ * adjustment based on perspective.
+ * 2. For thicker lines, creates four interpolators to define the line's
+ * rectangular area and fills it scanline by scanline.
+ * <p>
+ * Note: The width is scaled by the LINE_WIDTH_MULTIPLIER and adjusted based on
+ * the distance from the viewer (z-coordinate) to maintain a consistent visual size.
+ */
+public class Line extends AbstractCoordinateShape {
+
+    private static final double MINIMUM_WIDTH_THRESHOLD = 1;
+
+    private static final double LINE_WIDTH_MULTIPLIER = 0.2d;
+
+    /**
+     * Thread-local interpolators for line rendering.
+     * Each rendering thread gets its own array to avoid race conditions.
+     */
+    private static final ThreadLocal<LineInterpolator[]> LINE_INTERPOLATORS =
+            ThreadLocal.withInitial(() -> {
+                final LineInterpolator[] arr = new LineInterpolator[4];
+                for (int i = 0; i < arr.length; i++) {
+                    arr[i] = new LineInterpolator();
+                }
+                return arr;
+            });
+
+    /**
+     * width of the line.
+     */
+    public final double width;
+
+    /**
+     * Color of the line.
+     */
+    public Color color;
+
+    /**
+     * Creates a copy of an existing line with cloned coordinates and color.
+     *
+     * @param parentLine the line to copy
+     */
+    public Line(final Line parentLine) {
+        this(parentLine.vertices.get(0).coordinate.clone(),
+                parentLine.vertices.get(1).coordinate.clone(),
+                new Color(parentLine.color), parentLine.width);
+    }
+
+    /**
+     * Creates a line between two points with the specified color and width.
+     *
+     * @param point1 the starting point of the line
+     * @param point2 the ending point of the line
+     * @param color  the color of the line
+     * @param width  the width of the line in world units
+     */
+    public Line(final Point3D point1, final Point3D point2, final Color color,
+                final double width) {
+
+        super(
+                new Vertex(point1),
+                new Vertex(point2)
+        );
+
+        this.color = color;
+        this.width = width;
+    }
+
+    /**
+     * Draws a horizontal scanline between two interpolators with alpha blending.
+     *
+     * @param line1        the left edge interpolator
+     * @param line2        the right edge interpolator
+     * @param y            the Y coordinate of the scanline
+     * @param renderBuffer the rendering context to draw into
+     */
+    private void drawHorizontalLine(final LineInterpolator line1,
+                                    final LineInterpolator line2, final int y,
+                                    final RenderingContext renderBuffer) {
+
+        int x1 = line1.getX(y);
+        int x2 = line2.getX(y);
+
+        double d1 = line1.getD();
+        double d2 = line2.getD();
+
+        if (x1 > x2) {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+
+            final double tmp2 = d1;
+            d1 = d2;
+            d2 = tmp2;
+        }
+
+        final int unclippedWidth = x2 - x1;
+        final double dinc = (d2 - d1) / unclippedWidth;
+
+        if (x1 < renderBuffer.renderMinX) {
+            d1 += (dinc * (renderBuffer.renderMinX - x1));
+            x1 = renderBuffer.renderMinX;
+        }
+
+        // x2 is exclusive (loop paints [x1, x2)): clamp to renderMaxX,
+        // not renderMaxX-1, or the rightmost tile column stays unpainted
+        if (x2 >= renderBuffer.renderMaxX)
+            x2 = renderBuffer.renderMaxX;
+
+        final int drawnWidth = x2 - x1;
+
+        int offset = (y * renderBuffer.width) + x1;
+        final int[] pixels = renderBuffer.pixels;
+
+        final int lineAlpha = color.a;
+
+        final int colorR = color.r;
+        final int colorG = color.g;
+        final int colorB = color.b;
+
+        for (int i = 0; i < drawnWidth; i++) {
+
+            final double alphaMultiplier = 1d - Math.abs(d1);
+
+            final int realLineAlpha = (int) (lineAlpha * alphaMultiplier);
+            final int backgroundAlpha = 255 - realLineAlpha;
+
+            final int dest = pixels[offset];
+            final int destR = (dest >> 16) & 0xff;
+            final int destG = (dest >> 8) & 0xff;
+            final int destB = dest & 0xff;
+
+            final int newR = ((destR * backgroundAlpha) + (colorR * realLineAlpha)) >> 8;
+            final int newG = ((destG * backgroundAlpha) + (colorG * realLineAlpha)) >> 8;
+            final int newB = ((destB * backgroundAlpha) + (colorB * realLineAlpha)) >> 8;
+
+            pixels[offset++] = (newR << 16) | (newG << 8) | newB;
+
+            d1 += dinc;
+        }
+
+    }
+
+    /**
+     * Draws a thin line as single pixels with alpha-adjusted color.
+     * Used for lines that appear thin on screen (below minimum width threshold).
+     *
+     * @param buffer the rendering context to draw into
+     * @param alpha  the alpha value for the entire line
+     */
+    private void drawSinglePixelHorizontalLine(final RenderingContext buffer,
+                                               final int alpha,
+                                               final Point2D onScreenPoint1,
+                                               final Point2D onScreenPoint2) {
+        int xStart = (int) onScreenPoint1.x;
+        int xEnd = (int) onScreenPoint2.x;
+
+        int lineHeight;
+        int yBase;
+
+        if (xStart > xEnd) {
+            final int tmp = xStart;
+            xStart = xEnd;
+            xEnd = tmp;
+            lineHeight = (int) (onScreenPoint1.y - onScreenPoint2.y);
+            yBase = (int) onScreenPoint2.y;
+        } else {
+            yBase = (int) onScreenPoint1.y;
+            lineHeight = (int) (onScreenPoint2.y - onScreenPoint1.y);
+        }
+
+        final int lineWidth = xEnd - xStart;
+        if (lineWidth == 0)
+            return;
+
+        final int[] pixels = buffer.pixels;
+        final int backgroundAlpha = 255 - alpha;
+
+        final int redWithAlpha = color.r * alpha;
+        final int greenWithAlpha = color.g * alpha;
+        final int blueWithAlpha = color.b * alpha;
+
+        for (int relativeX = 0; relativeX <= lineWidth; relativeX++) {
+            final int x = xStart + relativeX;
+
+            if ((x >= buffer.renderMinX) && (x < buffer.renderMaxX)) {
+
+                final int y = yBase + ((relativeX * lineHeight) / lineWidth);
+                if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) {
+                    if ((y >= 0) && (y < buffer.height)) {
+                        int offset = (y * buffer.width) + x;
+
+                        final int dest = pixels[offset];
+                        final int destR = (dest >> 16) & 0xff;
+                        final int destG = (dest >> 8) & 0xff;
+                        final int destB = dest & 0xff;
+
+                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+
+                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                    }
+                }
+            }
+        }
+
+    }
+
+    /**
+     * Draws a thin vertical line as single pixels with alpha-adjusted color.
+     * Used for lines that appear thin on screen and are more vertical than horizontal.
+     *
+     * @param buffer the rendering context to draw into
+     * @param alpha  the alpha value for the entire line
+     */
+    private void drawSinglePixelVerticalLine(final RenderingContext buffer,
+                                             final int alpha,
+                                             final Point2D onScreenPoint1,
+                                             final Point2D onScreenPoint2) {
+        int yStart = (int) onScreenPoint1.y;
+        int yEnd = (int) onScreenPoint2.y;
+
+        int lineWidth;
+        int xBase;
+
+        if (yStart > yEnd) {
+            final int tmp = yStart;
+            yStart = yEnd;
+            yEnd = tmp;
+            lineWidth = (int) (onScreenPoint1.x - onScreenPoint2.x);
+            xBase = (int) onScreenPoint2.x;
+        } else {
+            xBase = (int) onScreenPoint1.x;
+            lineWidth = (int) (onScreenPoint2.x - onScreenPoint1.x);
+        }
+
+        final int lineHeight = yEnd - yStart;
+        if (lineHeight == 0)
+            return;
+
+        final int[] pixels = buffer.pixels;
+        final int backgroundAlpha = 255 - alpha;
+
+        final int redWithAlpha = color.r * alpha;
+        final int greenWithAlpha = color.g * alpha;
+        final int blueWithAlpha = color.b * alpha;
+
+        for (int relativeY = 0; relativeY <= lineHeight; relativeY++) {
+            final int y = yStart + relativeY;
+
+            if ((y >= buffer.renderMinY) && (y < buffer.renderMaxY)) {
+                if ((y >= 0) && (y < buffer.height)) {
+
+                    final int x = xBase + ((relativeY * lineWidth) / lineHeight);
+                    if ((x >= buffer.renderMinX) && (x < buffer.renderMaxX)) {
+                        int offset = (y * buffer.width) + x;
+
+                        final int dest = pixels[offset];
+                        final int destR = (dest >> 16) & 0xff;
+                        final int destG = (dest >> 8) & 0xff;
+                        final int destB = dest & 0xff;
+
+                        final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                        final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                        final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+
+                        pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                    }
+                }
+            }
+        }
+    }
+
+    /**
+     * Finds the index of the first interpolator (starting from startPointer) that contains the given Y coordinate.
+     *
+     * @param lineInterpolators the interpolators array
+     * @param startPointer      the index to start searching from
+     * @param y                 the Y coordinate to search for
+     * @return the index of the interpolator, or -1 if not found
+     */
+    private int getLineInterpolator(final LineInterpolator[] lineInterpolators,
+                                     final int startPointer, final int y) {
+
+        for (int i = startPointer; i < lineInterpolators.length; i++)
+            if (lineInterpolators[i].containsY(y))
+                return i;
+        return -1;
+    }
+
+    /**
+     * The thick-line path widens the line perpendicular to its direction by
+     * the projected endpoint radii, reaching beyond the vertex Y range.
+     * The thin paths stay within the vertex Y range, but there the radius
+     * is below 1 pixel anyway.
+     */
+    @Override
+    protected double getScreenYMargin(final RenderingContext renderingContext) {
+        final List<Vertex> clipped = clippedVertices(renderingContext);
+        final Vertex p1 = clipped != null ? clipped.get(0) : vertices.get(0);
+        final Vertex p2 = clipped != null ? clipped.get(1) : vertices.get(1);
+        final double point1radius = (renderingContext.width * LINE_WIDTH_MULTIPLIER * width)
+                / p1.transformedCoordinate(renderingContext).z;
+        final double point2radius = (renderingContext.width * LINE_WIDTH_MULTIPLIER * width)
+                / p2.transformedCoordinate(renderingContext).z;
+        return Math.max(point1radius, point2radius);
+    }
+
+    /**
+     * The thick-line widening is perpendicular to the line direction, so it
+     * reaches past the vertex range on the X axis exactly as much as on Y.
+     */
+    @Override
+    protected double getScreenXMargin(final RenderingContext renderingContext) {
+        return getScreenYMargin(renderingContext);
+    }
+
+    /**
+     * Renders this line to the screen using perspective-correct width and alpha blending.
+     *
+     * <p>This method handles two rendering modes:</p>
+     * <ul>
+     *   <li>Thin lines: When the projected width is below threshold, draws single-pixel
+     *       lines with alpha adjusted for sub-pixel appearance.</li>
+     *   <li>Thick lines: Creates four edge interpolators and fills the rectangular area
+     *       scanline by scanline with perspective-correct alpha fading at edges.</li>
+     * </ul>
+     *
+     * @param buffer the rendering context containing the pixel buffer
+     */
+    @Override
+    public void paint(final RenderingContext buffer) {
+        // Lines don't participate in depth (overlay semantics) — paint
+        // only in the alpha pass.
+        if (buffer.depthPass == 1)
+            return;
+
+        // Near-plane clip output takes precedence: a straddling line is
+        // shortened to its in-front endpoint plus the intersection point.
+        final List<Vertex> clipped = clippedVertices(buffer);
+        final Vertex endpoint1 = clipped != null ? clipped.get(0) : vertices.get(0);
+        final Vertex endpoint2 = clipped != null ? clipped.get(1) : vertices.get(1);
+
+        final Point2D onScreenPoint1 = endpoint1.onScreenCoordinate(buffer);
+        final Point2D onScreenPoint2 = endpoint2.onScreenCoordinate(buffer);
+
+        final double xp = onScreenPoint2.x - onScreenPoint1.x;
+        final double yp = onScreenPoint2.y - onScreenPoint1.y;
+
+        final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
+                / endpoint1.transformedCoordinate(buffer).z;
+        final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
+                / endpoint2.transformedCoordinate(buffer).z;
+
+        if ((point1radius < MINIMUM_WIDTH_THRESHOLD)
+                || (point2radius < MINIMUM_WIDTH_THRESHOLD)) {
+
+            double averageRadius = (point1radius + point2radius) / 2;
+
+            if (averageRadius > 1)
+                averageRadius = 1;
+
+            final int alpha = (int) (color.a * averageRadius);
+            if (alpha < 2)
+                return;
+
+            if (Math.abs(xp) > Math.abs(yp))
+                drawSinglePixelHorizontalLine(buffer, alpha, onScreenPoint1, onScreenPoint2);
+            else
+                drawSinglePixelVerticalLine(buffer, alpha, onScreenPoint1, onScreenPoint2);
+            return;
+        }
+
+        final double lineLength = Math.sqrt((xp * xp) + (yp * yp));
+
+        final double yinc1 = (point1radius * xp) / lineLength;
+        final double yinc2 = (point2radius * xp) / lineLength;
+
+        final double xdec1 = (point1radius * yp) / lineLength;
+        final double xdec2 = (point2radius * yp) / lineLength;
+
+        final double p1x1 = onScreenPoint1.x - xdec1;
+        final double p1y1 = onScreenPoint1.y + yinc1;
+
+        final double p1x2 = onScreenPoint1.x + xdec1;
+        final double p1y2 = onScreenPoint1.y - yinc1;
+
+        final double p2x1 = onScreenPoint2.x - xdec2;
+        final double p2y1 = onScreenPoint2.y + yinc2;
+
+        final double p2x2 = onScreenPoint2.x + xdec2;
+        final double p2y2 = onScreenPoint2.y - yinc2;
+
+        // Get thread-local interpolators
+        final LineInterpolator[] lineInterpolators = LINE_INTERPOLATORS.get();
+
+        lineInterpolators[0].setPoints(p1x1, p1y1, 1d, p2x1, p2y1, 1d);
+        lineInterpolators[1].setPoints(p1x2, p1y2, -1d, p2x2, p2y2, -1d);
+
+        lineInterpolators[2].setPoints(p1x1, p1y1, 1d, p1x2, p1y2, -1d);
+        lineInterpolators[3].setPoints(p2x1, p2y1, 1d, p2x2, p2y2, -1d);
+
+        double ymin = p1y1;
+        if (p1y2 < ymin)
+            ymin = p1y2;
+        if (p2y1 < ymin)
+            ymin = p2y1;
+        if (p2y2 < ymin)
+            ymin = p2y2;
+        if (ymin < 0)
+            ymin = 0;
+
+        double ymax = p1y1;
+        if (p1y2 > ymax)
+            ymax = p1y2;
+        if (p2y1 > ymax)
+            ymax = p2y1;
+        if (p2y2 > ymax)
+            ymax = p2y2;
+        if (ymax >= buffer.height)
+            ymax = buffer.height - 1;
+
+        // clamp to render Y bounds
+        ymin = Math.max(ymin, buffer.renderMinY);
+        ymax = Math.min(ymax, buffer.renderMaxY - 1);
+        if (ymin > ymax)
+            return;
+
+        for (int y = (int) ymin; y <= ymax; y++) {
+            final int li1 = getLineInterpolator(lineInterpolators, 0, y);
+            if (li1 != -1) {
+                final int li2 = getLineInterpolator(lineInterpolators, li1 + 1, y);
+                if (li2 != -1)
+                    drawHorizontalLine(lineInterpolators[li1], lineInterpolators[li2], y, buffer);
+            }
+        }
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineAppearance.java
new file mode 100644 (file)
index 0000000..e335b2f
--- /dev/null
@@ -0,0 +1,97 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+
+/**
+ * Factory for creating Line objects with consistent appearance settings.
+ * <p>
+ * This class encapsulates common line styling parameters (width and color) to
+ * avoid redundant configuration. It provides multiple constructors for
+ * flexibility and ensures default values are used when not specified.
+ *
+ * <p><b>Example usage:</b></p>
+ * <pre>{@code
+ * // Create a line appearance with default color and width 2.0
+ * LineAppearance appearance = new LineAppearance(2.0, Color.RED);
+ *
+ * // Create multiple lines with the same appearance
+ * Line line1 = appearance.getLine(new Point3D(0, 0, 100), new Point3D(10, 0, 100));
+ * Line line2 = appearance.getLine(new Point3D(0, 10, 100), new Point3D(10, 10, 100));
+ *
+ * // Override color for a specific line
+ * Line blueLine = appearance.getLine(p1, p2, Color.BLUE);
+ * }</pre>
+ */
+public class LineAppearance {
+
+    private final double lineWidth;
+
+    private Color color = new Color(100, 100, 255, 255);
+
+    /**
+     * Creates a line appearance with default width (1.0) and default color (light blue).
+     */
+    public LineAppearance() {
+        lineWidth = 1;
+    }
+
+    /**
+     * Creates a line appearance with the specified width and default color (light blue).
+     *
+     * @param lineWidth the line width in world units
+     */
+    public LineAppearance(final double lineWidth) {
+        this.lineWidth = lineWidth;
+    }
+
+    /**
+     * Creates a line appearance with the specified width and color.
+     *
+     * @param lineWidth the line width in world units
+     * @param color     the line color
+     */
+    public LineAppearance(final double lineWidth, final Color color) {
+        this.lineWidth = lineWidth;
+        this.color = color;
+    }
+
+    /**
+     * Creates a line between two points using this appearance's width and color.
+     *
+     * @param point1 the starting point of the line
+     * @param point2 the ending point of the line
+     * @return a new Line instance
+     */
+    public Line getLine(final Point3D point1, final Point3D point2) {
+        return new Line(point1, point2, color, lineWidth);
+    }
+
+    /**
+     * Creates a line between two points using this appearance's width and a custom color.
+     *
+     * @param point1 the starting point of the line
+     * @param point2 the ending point of the line
+     * @param color  the color for this specific line (overrides the default)
+     * @return a new Line instance
+     */
+    public Line getLine(final Point3D point1, final Point3D point2,
+                        final Color color) {
+        return new Line(point1, point2, color, lineWidth);
+    }
+
+    /**
+     * Returns the line width configured for this appearance.
+     *
+     * @return the line width in world units
+     */
+    public double getLineWidth() {
+        return lineWidth;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java
new file mode 100644 (file)
index 0000000..83e8c6a
--- /dev/null
@@ -0,0 +1,101 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line;
+
+/**
+ * Interpolates between two points along a line for scanline rendering.
+ * <p>
+ * This class calculates screen coordinates and depth values (d) for a given Y
+ * position. It supports perspective-correct interpolation by tracking the
+ * distance between points and using it to compute step increments.
+ * <p>
+ * The comparison logic prioritizes interpolators with greater vertical coverage
+ * to optimize scanline ordering.
+ */
+public class LineInterpolator {
+
+    private double x1, y1, d1, x2, y2, d2;
+
+    private double d;
+    private int height;
+    private int width;
+    private double dinc;
+
+    /**
+     * Creates a new line interpolator with uninitialized endpoints.
+     */
+    public LineInterpolator() {
+    }
+
+    /**
+     * Checks if the given Y coordinate falls within the vertical span of this line.
+     *
+     * @param y the Y coordinate to test
+     * @return {@code true} if y is between y1 and y2 (inclusive)
+     */
+    public boolean containsY(final int y) {
+
+        if (y1 < y2) {
+            if (y >= y1)
+                return y <= y2;
+        } else if (y >= y2)
+            return y <= y1;
+
+        return false;
+    }
+
+    /**
+     * Returns the depth value (d) at the current Y position.
+     *
+     * @return the interpolated depth value
+     */
+    public double getD() {
+        return d;
+    }
+
+    /**
+     * Computes the X coordinate for the given Y position.
+     *
+     * @param y the Y coordinate
+     * @return the interpolated X coordinate
+     */
+    public int getX(final int y) {
+        if (height == 0)
+            return (int) (x2 + x1) / 2;
+
+        final int distanceFromY1 = y - (int) y1;
+
+        d = d1 + ((dinc * distanceFromY1) / height);
+
+        return (int) x1 + ((width * distanceFromY1) / height);
+    }
+
+    /**
+     * Sets the endpoints and depth values for this line interpolator.
+     *
+     * @param x1 the X coordinate of the first point
+     * @param y1 the Y coordinate of the first point
+     * @param d1 the depth value at the first point
+     * @param x2 the X coordinate of the second point
+     * @param y2 the Y coordinate of the second point
+     * @param d2 the depth value at the second point
+     */
+    public void setPoints(final double x1, final double y1, final double d1,
+                          final double x2, final double y2, final double d2) {
+
+        this.x1 = x1;
+        this.y1 = y1;
+        this.d1 = d1;
+
+        this.x2 = x2;
+        this.y2 = y2;
+        this.d2 = d2;
+
+        height = (int) y2 - (int) y1;
+        width = (int) x2 - (int) x1;
+
+        dinc = d2 - d1;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/line/package-info.java
new file mode 100644 (file)
index 0000000..1e3032d
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * 3D line segment rendering with perspective-correct width and alpha blending.
+ *
+ * <p>Lines are rendered with width that adjusts based on distance from the viewer.
+ * The rendering uses interpolators for smooth edges and proper alpha blending.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line} - The line shape</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance} - Color and width configuration</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineInterpolator} - Scanline edge interpolation</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/package-info.java
new file mode 100644 (file)
index 0000000..57d2f80
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Primitive shape implementations for the rasterization pipeline.
+ *
+ * <p>Basic shapes are the building blocks of 3D scenes. Each can be rendered
+ * independently and combined to create more complex objects.</p>
+ *
+ * <p>Subpackages:</p>
+ * <ul>
+ *   <li>{@code line} - 3D line segments with perspective-correct width</li>
+ *   <li>{@code solidpolygon} - Solid-color triangles with flat shading</li>
+ *   <li>{@code texturedpolygon} - Triangles with UV-mapped textures</li>
+ * </ul>
+ *
+ * <p>Additional basic shapes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard} - Textures that always face the camera</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint} - Circular gradient billboards</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java
new file mode 100644 (file)
index 0000000..6fcef60
--- /dev/null
@@ -0,0 +1,151 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+
+import static java.lang.Math.round;
+
+/**
+ * Interpolates the x coordinate along a 2D line edge for scanline-based polygon rasterization.
+ *
+ * <p>{@code LineInterpolator} represents one edge of a polygon in screen space, defined by
+ * two {@link Point2D} endpoints. Given a scanline y coordinate, it computes the corresponding
+ * x coordinate via linear interpolation. This is a core building block for the solid polygon
+ * rasterizer, which fills triangles by sweeping horizontal scanlines and using two
+ * {@code LineInterpolator} instances to find the left and right x boundaries at each y level.</p>
+ *
+ * <p><b>Subpixel precision:</b> This class uses double-precision arithmetic throughout
+ * the interpolation pipeline to eliminate T-junction gaps. Vertices that should be at the
+ * same position but land at slightly different screen coordinates (e.g., 100.4 vs 100.6)
+ * will produce consistent interpolated results when rounded, ensuring adjacent polygons
+ * fill seamlessly without gaps.</p>
+ *
+ * <p>Instances are {@link Comparable}, sorted by absolute height (tallest first) and then
+ * by width. This ordering is used during rasterization to select the primary (longest) edge
+ * of the triangle for the outer scanline loop.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ * @see Point2D
+ */
+public class LineInterpolator {
+
+    /**
+     * Small epsilon value for comparing near-zero heights to detect horizontal edges.
+     */
+    private static final double EPSILON = 0.0001;
+    /**
+     * The first endpoint of this edge.
+     */
+    Point2D p1;
+    /**
+     * The second endpoint of this edge.
+     */
+    Point2D p2;
+    /**
+     * The vertical span (p2.y - p1.y) in double precision, which may be negative.
+     *
+     * <p>Stored as double to preserve subpixel precision during interpolation,
+     * eliminating rounding errors that cause T-junction gaps.</p>
+     */
+    private double height;
+    /**
+     * The horizontal span (p2.x - p1.x) in double precision, which may be negative.
+     *
+     * <p>Stored as double to preserve subpixel precision during interpolation.</p>
+     */
+    private double width;
+
+    /**
+     * 1/z (the depth-buffer quantity) at the endpoints. Linear in screen
+     * space, so it rides the same edge interpolation as x; set only via
+     * {@link #setPointsZW} when the polygon participates in the z-buffer.
+     */
+    private double zw1;
+    private double zw2;
+    private double zwSpan;
+
+    /**
+     * Creates a new line interpolator with uninitialized endpoints.
+     */
+    public LineInterpolator() {
+    }
+
+    /**
+     * Tests whether the given y coordinate falls within the vertical span of this edge.
+     *
+     * <p>Uses double-precision comparison to handle subpixel vertex positions correctly.</p>
+     *
+     * @param y the scanline y coordinate to test
+     * @return {@code true} if {@code y} is between the y coordinates of the two endpoints (inclusive)
+     */
+    public boolean containsY(final int y) {
+        final double minY = Math.min(p1.y, p2.y);
+        final double maxY = Math.max(p1.y, p2.y);
+        return y >= minY && y <= maxY;
+    }
+
+    /**
+     * Computes the interpolated x coordinate rounded to the nearest integer.
+     *
+     * <p>For horizontal edges (height near zero), returns the midpoint x value
+     * to avoid division by zero. This case should only occur when the edge
+     * spans exactly one scanline.</p>
+     *
+     * @param y the scanline y coordinate
+     * @return the interpolated x coordinate rounded to the nearest integer
+     */
+    public int getX(final int y) {
+        if (Math.abs(height) < EPSILON) {
+            return (int) round((p1.x + p2.x) / 2);
+        }
+        return (int) round(p1.x + (width * (y - p1.y)) / height);
+    }
+
+    /**
+     * Sets the two endpoints of this edge and precomputes the width, height, and absolute height.
+     *
+     * <p>This method stores the endpoints directly and computes spans using double-precision
+     * arithmetic from the Point2D coordinates.</p>
+     *
+     * @param p1 the first endpoint
+     * @param p2 the second endpoint
+     */
+    public void setPoints(final Point2D p1, final Point2D p2) {
+        this.p1 = p1;
+        this.p2 = p2;
+        height = p2.y - p1.y;
+        width = p2.x - p1.x;
+    }
+
+    /**
+     * Sets the depth-buffer endpoint values (1/z) for this edge.
+     * Companion to {@link #setPoints}; call after it.
+     *
+     * @param zw1 1/z at {@code p1}
+     * @param zw2 1/z at {@code p2}
+     */
+    public void setPointsZW(final double zw1, final double zw2) {
+        this.zw1 = zw1;
+        this.zw2 = zw2;
+        zwSpan = zw2 - zw1;
+    }
+
+    /**
+     * Computes the interpolated 1/z (depth value) at the given scanline.
+     * Uses the same interpolation parameter as {@link #getX}, so depth
+     * and x stay consistent along the edge.
+     *
+     * @param y the scanline y coordinate
+     * @return the interpolated 1/z
+     */
+    public double getZW(final int y) {
+        if (Math.abs(height) < EPSILON) {
+            return (zw1 + zw2) / 2d;
+        }
+        return zw1 + (zwSpan * (y - p1.y)) / height;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
new file mode 100644 (file)
index 0000000..6dd3b19
--- /dev/null
@@ -0,0 +1,819 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Plane;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static eu.svjatoslav.aukio.e3d.geometry.Polygon.pointWithinPolygon;
+
+/**
+ * A solid-color convex polygon renderer supporting N vertices (N >= 3).
+ *
+ * <p>This class serves as the unified polygon type for both rendering and CSG operations.
+ * It renders convex polygons by decomposing them into triangles using fan triangulation,
+ * and supports CSG operations directly without conversion to intermediate types.</p>
+ *
+ * <p><b>Rendering:</b></p>
+ * <ul>
+ *   <li>Fan triangulation for N-vertex polygons (N-2 triangles)</li>
+ *   <li>Scanline rasterization with per-pixel z-buffering and alpha blending</li>
+ *   <li>Backface culling and flat shading support</li>
+ *   <li>Mouse interaction via point-in-polygon testing</li>
+ * </ul>
+ *
+ * <p><b>CSG Support:</b></p>
+ * <ul>
+ *   <li>Lazy-computed plane for BSP operations</li>
+ *   <li>{@link #flip()} for inverting polygon orientation</li>
+ *   <li>{@link #deepClone()} for creating independent copies</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Create a triangle
+ * SolidPolygon triangle = new SolidPolygon(
+ *     new Point3D(0, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     new Point3D(25, 50, 0),
+ *     Color.RED
+ * );
+ *
+ * // Create a quad
+ * SolidPolygon quad = SolidPolygon.quad(
+ *     new Point3D(-50, -50, 0),
+ *     new Point3D(50, -50, 0),
+ *     new Point3D(50, 50, 0),
+ *     new Point3D(-50, 50, 0),
+ *     Color.BLUE
+ * );
+ *
+ * // Use with CSG (via AbstractCompositeShape)
+ * SolidPolygonRectangularBox box = new SolidPolygonRectangularBox(...);
+ * box.subtract(sphere);
+ * }</pre>
+ *
+ * @see Plane for BSP plane operations
+ * @see LineInterpolator for scanline edge interpolation
+ */
+public class SolidPolygon extends AbstractCoordinateShape {
+
+    /**
+     * Thread-local storage for line interpolators used during scanline rasterization.
+     *
+     * <p>Contains three interpolators representing the three edges of a triangle.
+     * ThreadLocal ensures thread safety when multiple threads render triangles
+     * concurrently, avoiding allocation during rendering by reusing these objects.</p>
+     */
+    private static final ThreadLocal<LineInterpolator[]> INTERPOLATORS =
+            ThreadLocal.withInitial(() -> new LineInterpolator[]{
+                    new LineInterpolator(), new LineInterpolator(), new LineInterpolator()
+            });
+    /**
+     * Thread-local storage for screen coordinates during rendering.
+     * Each rendering thread gets its own array to avoid race conditions.
+     */
+    private static final ThreadLocal<Point2D[]> SCREEN_POINTS = new ThreadLocal<>();
+    /**
+     * Reusable color for shading calculations.
+     * Computed once during transform phase, used during paint phase.
+     */
+    private final Color shadedColor = new Color();
+    /**
+     * Reusable point for polygon center calculation.
+     */
+    private final Point3D cachedCenter = new Point3D();
+    /**
+     * Reusable point for polygon normal calculation.
+     */
+    private final Point3D cachedNormal = new Point3D();
+    /**
+     * Cached plane containing this polygon, used for BSP operations.
+     *
+     * <p>Lazy-computed on first call to {@link #getPlane()}.</p>
+     */
+    private Plane plane;
+    /**
+     * The fill color of this polygon.
+     */
+    private Color color;
+    /**
+     * Whether flat shading is enabled for this polygon.
+     */
+    private boolean shadingEnabled = false;
+
+    /**
+     * Whether backface culling is enabled for this polygon.
+     */
+    private boolean backfaceCulling = false;
+
+    // ==================== CONSTRUCTORS ====================
+
+    /**
+     * Creates a solid polygon with the specified vertices and color.
+     *
+     * @param vertices the vertices defining the polygon (must have at least 3)
+     * @param color    the fill color of the polygon
+     * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices
+     */
+    public SolidPolygon(final Point3D[] vertices, final Color color) {
+        super(createVerticesFromPoints(vertices));
+        if (vertices == null || vertices.length < 3) {
+            throw new IllegalArgumentException(
+                    "Polygon must have at least 3 vertices, but got "
+                            + (vertices == null ? "null" : vertices.length));
+        }
+        this.color = color;
+    }
+
+    /**
+     * Creates a solid polygon from a list of points and color.
+     *
+     * @param points the list of points defining the polygon (must have at least 3)
+     * @param color  the fill color of the polygon
+     * @throws IllegalArgumentException if points is null or has fewer than 3 points
+     */
+    public SolidPolygon(final List<Point3D> points, final Color color) {
+        super(createVerticesFromPoints(points));
+        if (points == null || points.size() < 3) {
+            throw new IllegalArgumentException(
+                    "Polygon must have at least 3 vertices, but got "
+                            + (points == null ? "null" : points.size()));
+        }
+        this.color = color;
+    }
+
+    /**
+     * Private constructor for creating a polygon from existing vertices.
+     *
+     * <p>Parameter order (color first) avoids erasure conflict with
+     * {@link #SolidPolygon(List, Color)} which takes List&lt;Point3D&gt;.</p>
+     *
+     * @param color    the fill color of the polygon
+     * @param vertices the list of Vertex objects (used directly, not copied)
+     */
+    private SolidPolygon(final Color color, final List<Vertex> vertices) {
+        super(vertices);
+        this.color = color;
+    }
+
+    /**
+     * Creates a solid triangle with the specified vertices and color.
+     *
+     * @param point1 the first vertex position
+     * @param point2 the second vertex position
+     * @param point3 the third vertex position
+     * @param color  the fill color
+     */
+    public SolidPolygon(final Point3D point1, final Point3D point2,
+                        final Point3D point3, final Color color) {
+        super(new Vertex(point1), new Vertex(point2), new Vertex(point3));
+        this.color = color;
+    }
+
+    /**
+     * Creates a solid polygon from existing vertices.
+     *
+     * <p>Used for CSG operations and cloning where vertices already exist.
+     * The vertex list is used directly (not copied), so callers should not
+     * modify the list after passing it to this method.</p>
+     *
+     * @param vertices the list of Vertex objects (used directly, not copied)
+     * @param color    the fill color of the polygon
+     * @return a new SolidPolygon with the given vertices (shading disabled by default)
+     * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices
+     */
+    public static SolidPolygon fromVertices(final List<Vertex> vertices, final Color color) {
+        return fromVertices(vertices, color, false);
+    }
+
+    /**
+     * Creates a solid polygon from existing vertices with specified shading.
+     *
+     * <p>Used for CSG operations and cloning where vertices already exist.
+     * The vertex list is used directly (not copied), so callers should not
+     * modify the list after passing it to this method.</p>
+     *
+     * @param vertices       the list of Vertex objects (used directly, not copied)
+     * @param color          the fill color of the polygon
+     * @param shadingEnabled whether shading is enabled for this polygon
+     * @return a new SolidPolygon with the given vertices and shading setting
+     * @throws IllegalArgumentException if vertices is null or has fewer than 3 vertices
+     */
+    public static SolidPolygon fromVertices(final List<Vertex> vertices, final Color color,
+                                            final boolean shadingEnabled) {
+        if (vertices == null || vertices.size() < 3) {
+            throw new IllegalArgumentException(
+                    "Polygon must have at least 3 vertices, but got "
+                            + (vertices == null ? "null" : vertices.size()));
+        }
+        final SolidPolygon polygon = new SolidPolygon(color, vertices);
+        polygon.setShadingEnabled(shadingEnabled);
+        return polygon;
+    }
+
+    // ==================== STATIC FACTORY METHODS ====================
+
+    /**
+     * Creates a triangle (3-vertex polygon).
+     *
+     * @param p1    the first vertex
+     * @param p2    the second vertex
+     * @param p3    the third vertex
+     * @param color the fill color
+     * @return a new SolidPolygon with 3 vertices
+     */
+    public static SolidPolygon triangle(final Point3D p1, final Point3D p2,
+                                        final Point3D p3, final Color color) {
+        return new SolidPolygon(p1, p2, p3, color);
+    }
+
+    /**
+     * Creates a quad (4-vertex polygon).
+     *
+     * @param p1    the first vertex
+     * @param p2    the second vertex
+     * @param p3    the third vertex
+     * @param p4    the fourth vertex
+     * @param color the fill color
+     * @return a new SolidPolygon with 4 vertices
+     */
+    public static SolidPolygon quad(final Point3D p1, final Point3D p2,
+                                    final Point3D p3, final Point3D p4, final Color color) {
+        return new SolidPolygon(new Point3D[]{p1, p2, p3, p4}, color);
+    }
+
+    // ==================== VERTEX HELPER METHODS ====================
+
+    /**
+     * Helper method to create Vertex list from Point3D array.
+     */
+    private static List<Vertex> createVerticesFromPoints(final Point3D[] points) {
+        if (points == null || points.length < 3) {
+            return new ArrayList<>();
+        }
+        final List<Vertex> verts = new ArrayList<>(points.length);
+        for (final Point3D point : points) {
+            verts.add(new Vertex(point));
+        }
+        return verts;
+    }
+
+    /**
+     * Helper method to create Vertex list from Point3D list.
+     */
+    private static List<Vertex> createVerticesFromPoints(final List<Point3D> points) {
+        if (points == null || points.size() < 3) {
+            return new ArrayList<>();
+        }
+        final List<Vertex> verts = new ArrayList<>(points.size());
+        for (final Point3D point : points) {
+            verts.add(new Vertex(point));
+        }
+        return verts;
+    }
+
+    /**
+     * Draws a horizontal scanline between two edge interpolators with alpha
+     * blending and per-pixel depth testing.
+     *
+     * <p>The 1/z endpoint values ride the interpolators' zw channel; every
+     * pixel is depth-tested before writing. Opaque pixels write depth (pass
+     * 1), translucent pixels blend without a depth write (pass 2), so
+     * translucency never occludes.</p>
+     *
+     * @param line1        the left edge interpolator
+     * @param line2        the right edge interpolator
+     * @param y            the Y coordinate of the scanline
+     * @param renderBuffer the rendering context to draw into
+     * @param color        the color to draw with
+     */
+    private static void drawHorizontalLine(final LineInterpolator line1,
+                                           final LineInterpolator line2, final int y,
+                                           final RenderingContext renderBuffer, final Color color) {
+
+        int x1 = line1.getX(y);
+        int x2 = line2.getX(y);
+
+        double zw1 = line1.getZW(y);
+        double zw2 = line2.getZW(y);
+
+        if (x1 > x2) {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+            final double tmpZw = zw1;
+            zw1 = zw2;
+            zw2 = tmpZw;
+        }
+
+        final double realX1 = x1;
+        final double realWidth = x2 - x1;
+
+        if (x1 < renderBuffer.renderMinX) x1 = renderBuffer.renderMinX;
+
+        // x2 is exclusive (loop paints [x1, x2)): clamp to renderMaxX,
+        // not renderMaxX-1, or the rightmost tile column stays unpainted
+        if (x2 >= renderBuffer.renderMaxX) x2 = renderBuffer.renderMaxX;
+
+        final int width = x2 - x1;
+        if (width <= 0)
+            return;
+
+        int offset = (y * renderBuffer.width) + x1;
+        final int[] pixels = renderBuffer.pixels;
+        final float[] depth = renderBuffer.depth;
+        // Alpha pass (depthPass 2): depth-test but never depth-write
+        final boolean writeDepth = renderBuffer.depthPass != 2;
+
+        final double dzw = (zw2 - zw1) / realWidth;
+        double zw = zw1 + dzw * (x1 - realX1);
+
+        final int polygonAlpha = color.a;
+        final int r = color.r;
+        final int g = color.g;
+        final int b = color.b;
+
+        if (polygonAlpha == 255) {
+            final int pixel = (r << 16) | (g << 8) | b;
+            for (int i = 0; i < width; i++) {
+                if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                    pixels[offset] = pixel;
+                    if (writeDepth)
+                        depth[offset] = (float) zw;
+                }
+                offset++;
+                zw += dzw;
+            }
+        } else {
+            final int backgroundAlpha = 255 - polygonAlpha;
+
+            final int redWithAlpha = r * polygonAlpha;
+            final int greenWithAlpha = g * polygonAlpha;
+            final int blueWithAlpha = b * polygonAlpha;
+
+            for (int i = 0; i < width; i++) {
+                if (zw > depth[offset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                    final int dest = pixels[offset];
+                    final int destR = (dest >> 16) & 0xff;
+                    final int destG = (dest >> 8) & 0xff;
+                    final int destB = dest & 0xff;
+
+                    final int newR = ((destR * backgroundAlpha) + redWithAlpha) >> 8;
+                    final int newG = ((destG * backgroundAlpha) + greenWithAlpha) >> 8;
+                    final int newB = ((destB * backgroundAlpha) + blueWithAlpha) >> 8;
+
+                    pixels[offset] = (newR << 16) | (newG << 8) | newB;
+                }
+                offset++;
+                zw += dzw;
+            }
+        }
+    }
+
+    /**
+     * Renders a triangle using scanline rasterization.
+     *
+     * <p>This static method handles:</p>
+     * <ul>
+     *   <li>Rounding vertices to integer screen coordinates</li>
+     *   <li>Mouse hover detection via point-in-triangle test</li>
+     *   <li>Viewport clipping</li>
+     *   <li>Scanline rasterization with per-pixel z-buffering and alpha blending</li>
+     * </ul>
+     *
+     * @param context                    the rendering context
+     * @param onScreenPoint1             the first vertex in screen coordinates
+     * @param onScreenPoint2             the second vertex in screen coordinates
+     * @param onScreenPoint3             the third vertex in screen coordinates
+     * @param z1                         camera-space z of the first vertex
+     * @param z2                         camera-space z of the second vertex
+     * @param z3                         camera-space z of the third vertex
+     * @param mouseInteractionController optional controller for mouse events, or null
+     * @param color                      the fill color
+     */
+    public static void drawTriangle(final RenderingContext context,
+                                    final Point2D onScreenPoint1, final Point2D onScreenPoint2,
+                                    final Point2D onScreenPoint3,
+                                    final double z1, final double z2, final double z3,
+                                    final MouseInteractionController mouseInteractionController,
+                                    final Color color) {
+
+        if (mouseInteractionController != null)
+            if (context.getMouseEvent() != null)
+                if (pointWithinPolygon(context.getMouseEvent().coordinate, onScreenPoint1, onScreenPoint2, onScreenPoint3))
+                    context.setCurrentObjectUnderMouseCursor(mouseInteractionController);
+
+        if (color.isTransparent()) return;
+
+        // Copy coordinates to local variables (don't modify original Point2D)
+        // Keep double precision to eliminate T-junction gaps from truncation errors
+        final double y1 = onScreenPoint1.y;
+        final double y2 = onScreenPoint2.y;
+        final double y3 = onScreenPoint3.y;
+
+        // Find top-most point (use ceil to include all pixels triangle touches)
+        int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3)));
+        if (yTop < 0) yTop = 0;
+
+        // Find bottom-most point (use floor to include all pixels triangle touches)
+        int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3)));
+        if (yBottom >= context.height) yBottom = context.height - 1;
+
+        // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive)
+        yTop = Math.max(yTop, context.renderMinY);
+        yBottom = Math.min(yBottom, context.renderMaxY - 1);
+        if (yTop > yBottom) return;
+
+        // Paint using line interpolators
+        final LineInterpolator[] interp = INTERPOLATORS.get();
+        final LineInterpolator li1 = interp[0];
+        final LineInterpolator li2 = interp[1];
+        final LineInterpolator li3 = interp[2];
+        li1.setPoints(onScreenPoint1, onScreenPoint2);
+        li2.setPoints(onScreenPoint1, onScreenPoint3);
+        li3.setPoints(onScreenPoint2, onScreenPoint3);
+
+        // 1/z rides the same edge interpolation; spans depth-test per pixel
+        li1.setPointsZW(1d / z1, 1d / z2);
+        li2.setPointsZW(1d / z1, 1d / z3);
+        li3.setPointsZW(1d / z2, 1d / z3);
+
+        for (int y = yTop; y <= yBottom; y++) {
+            if (li1.containsY(y)) {
+                if (li2.containsY(y)) {
+                    drawHorizontalLine(li1, li2, y, context, color);
+                } else if (li3.containsY(y)) {
+                    drawHorizontalLine(li1, li3, y, context, color);
+                }
+            } else if (li2.containsY(y)) {
+                if (li3.containsY(y)) {
+                    drawHorizontalLine(li2, li3, y, context, color);
+                }
+            }
+        }
+    }
+
+    /**
+     * Returns the number of vertices in this polygon.
+     *
+     * @return the vertex count
+     */
+    public int getVertexCount() {
+        return vertices.size();
+    }
+
+    /**
+     * Returns the fill color of this polygon.
+     *
+     * @return the polygon color
+     */
+    public Color getColor() {
+        return color;
+    }
+
+    /**
+     * Sets the fill color of this polygon.
+     *
+     * @param color the new color
+     */
+    public void setColor(final Color color) {
+        this.color = color;
+    }
+
+    /**
+     * Checks if shading is enabled for this polygon.
+     *
+     * @return true if shading is enabled, false otherwise
+     */
+    public boolean isShadingEnabled() {
+        return shadingEnabled;
+    }
+
+    /**
+     * Enables or disables shading for this polygon.
+     *
+     * @param shadingEnabled true to enable shading, false to disable
+     */
+    public void setShadingEnabled(final boolean shadingEnabled) {
+        this.shadingEnabled = shadingEnabled;
+    }
+
+    // ==================== CSG SUPPORT ====================
+
+    /**
+     * Checks if backface culling is enabled for this polygon.
+     *
+     * @return {@code true} if backface culling is enabled
+     */
+    public boolean isBackfaceCullingEnabled() {
+        return backfaceCulling;
+    }
+
+    /**
+     * Enables or disables backface culling for this polygon.
+     *
+     * @param backfaceCulling {@code true} to enable backface culling
+     */
+    public void setBackfaceCulling(final boolean backfaceCulling) {
+        this.backfaceCulling = backfaceCulling;
+    }
+
+    /**
+     * Returns the plane containing this polygon.
+     *
+     * <p>Computed from the first three vertices and cached for reuse.
+     * Used by BSP tree construction for spatial partitioning.</p>
+     *
+     * @return the Plane containing this polygon
+     */
+    public Plane getPlane() {
+        if (plane == null) {
+            plane = Plane.fromPoints(
+                    vertices.get(0).coordinate,
+                    vertices.get(1).coordinate,
+                    vertices.get(2).coordinate
+            );
+        }
+        return plane;
+    }
+
+    // ==================== RENDERING ====================
+
+    /**
+     * Flips the orientation of this polygon.
+     *
+     * <p>Reverses the vertex order and negates vertex normals.
+     * Also flips the cached plane if computed. Used during CSG operations
+     * when inverting solids.</p>
+     */
+    public void flip() {
+        Collections.reverse(vertices);
+        for (final Vertex vertex : vertices) vertex.flip();
+        if (plane != null) plane.flip();
+    }
+
+    /**
+     * Creates a deep clone of this polygon.
+     *
+     * <p>Clones all vertices and preserves the color, shading, and backface culling settings.
+     * Used by CSG operations to create independent copies before modification.</p>
+     *
+     * @return a new SolidPolygon with cloned data and preserved settings
+     */
+    public SolidPolygon deepClone() {
+        final List<Vertex> clonedVertices = new ArrayList<>(vertices.size());
+        for (final Vertex v : vertices) {
+            clonedVertices.add(v.clone());
+        }
+        final SolidPolygon clone = SolidPolygon.fromVertices(clonedVertices, color, shadingEnabled);
+        clone.backfaceCulling = this.backfaceCulling;
+        return clone;
+    }
+
+    /**
+     * Calculates the centroid (geometric center) of this polygon.
+     *
+     * @param result the point to store the center in
+     */
+    private void calculateCenter(final Point3D result) {
+        if (vertices.isEmpty()) {
+            result.x = result.y = result.z = 0;
+            return;
+        }
+
+        double sumX = 0, sumY = 0, sumZ = 0;
+        for (final Vertex v : vertices) {
+            sumX += v.coordinate.x;
+            sumY += v.coordinate.y;
+            sumZ += v.coordinate.z;
+        }
+
+        result.x = sumX / vertices.size();
+        result.y = sumY / vertices.size();
+        result.z = sumZ / vertices.size();
+    }
+
+    /**
+     * Calculates the signed area of this polygon in screen space.
+     *
+     * @param screenPoints the screen coordinates of this polygon's vertices
+     * @param vertexCount  the number of vertices in the polygon
+     * @return the signed area (negative = front-facing in Y-down coordinate system)
+     */
+    private double calculateSignedArea(final Point2D[] screenPoints, final int vertexCount) {
+        double area = 0;
+        final int n = vertexCount;
+        for (int i = 0; i < n; i++) {
+            final Point2D curr = screenPoints[i];
+            final Point2D next = screenPoints[(i + 1) % n];
+            area += curr.x * next.y - next.x * curr.y;
+        }
+        return area / 2.0;
+    }
+
+    /**
+     * Tests whether a point lies inside this polygon using ray-casting.
+     *
+     * @param point        the point to test
+     * @param screenPoints the screen coordinates of this polygon's vertices
+     * @param vertexCount  the number of vertices in the polygon
+     * @return {@code true} if the point is inside the polygon
+     */
+    private boolean isPointInsidePolygon(final Point2D point, final Point2D[] screenPoints,
+                                         final int vertexCount) {
+        int intersectionCount = 0;
+        final int n = vertexCount;
+
+        for (int i = 0; i < n; i++) {
+            final Point2D p1 = screenPoints[i];
+            final Point2D p2 = screenPoints[(i + 1) % n];
+
+            if (intersectsRay(point, p1, p2)) {
+                intersectionCount++;
+            }
+        }
+
+        return (intersectionCount % 2) == 1;
+    }
+
+    /**
+     * Tests if a horizontal ray from the point intersects the edge.
+     */
+    private boolean intersectsRay(final Point2D point, Point2D edgeP1, Point2D edgeP2) {
+        if (edgeP1.y > edgeP2.y) {
+            final Point2D tmp = edgeP1;
+            edgeP1 = edgeP2;
+            edgeP2 = tmp;
+        }
+
+        if (point.y < edgeP1.y || point.y > edgeP2.y) {
+            return false;
+        }
+
+        final double dy = edgeP2.y - edgeP1.y;
+        if (Math.abs(dy) < 0.0001) {
+            return false;
+        }
+
+        final double t = (point.y - edgeP1.y) / dy;
+        final double intersectX = edgeP1.x + t * (edgeP2.x - edgeP1.x);
+
+        return point.x >= intersectX;
+    }
+
+    /**
+     * Renders this polygon to the screen.
+     *
+     * @param renderBuffer the rendering context containing the pixel buffer
+     */
+    @Override
+    public void paint(final RenderingContext renderBuffer) {
+        // Near-plane clip output takes precedence: a straddling triangle
+        // becomes a triangle or quad of in-front vertices; the original
+        // vertices hold behind-camera positions with garbage projections.
+        final List<Vertex> clipped = clippedVertices(renderBuffer);
+        final List<Vertex> active = clipped != null ? clipped : vertices;
+
+        if (active.size() < 3 || color.isTransparent()) {
+            return;
+        }
+
+        // Use pre-computed shaded color (computed during transform phase)
+        final Color paintColor = shadingEnabled ? shadedColor : color;
+
+        // Z-buffer two-pass classification: opaque polygons paint in
+        // pass 1 (depth test + write), translucent ones in pass 2
+        // (depth test, no write — translucency must not occlude).
+        // See RenderAggregator.paintSorted.
+        final boolean alphaClass = paintColor.a != 255;
+        if ((renderBuffer.depthPass == 1) == alphaClass)
+            return;
+
+        // Get thread-local screen points array
+        final Point2D[] screenPoints = getScreenPoints(active.size());
+        final double[] cameraZ = getCameraZ(active.size());
+
+        // Get screen coordinates and per-vertex depth
+        for (int i = 0; i < active.size(); i++) {
+            final Vertex vertex = active.get(i);
+            screenPoints[i] = vertex.onScreenCoordinate(renderBuffer);
+            cameraZ[i] = vertex.transformedCoordinate(renderBuffer).z;
+        }
+
+        // Backface culling check
+        if (backfaceCulling) {
+            final double signedArea = calculateSignedArea(screenPoints, active.size());
+            if (signedArea >= 0) {
+                return;
+            }
+        }
+
+        // Mouse interaction
+        if (mouseInteractionController != null && renderBuffer.getMouseEvent() != null) {
+            if (isPointInsidePolygon(renderBuffer.getMouseEvent().coordinate, screenPoints, active.size())) {
+                renderBuffer.setCurrentObjectUnderMouseCursor(mouseInteractionController);
+            }
+        }
+
+        // Only triangles can be rendered directly; N-vertex polygons must be triangulated
+        // by AbstractCompositeShape.rebuildRenderList() before rendering. The single
+        // exception: near-plane clipping can turn a renderable triangle into a quad
+        // (one corner cut off), painted here as a 2-triangle fan — the clip of a
+        // convex polygon stays convex, so fan triangulation is exact.
+        if (clipped == null && active.size() != 3) {
+            throw new IllegalStateException(
+                    "SolidPolygon with " + active.size() + " vertices cannot be rendered directly. "
+                            + "Only triangles (3 vertices) support direct rendering. "
+                            + "For N-vertex polygons, use AbstractCompositeShape which triangulates when building its render list.");
+        }
+
+        for (int i = 1; i + 1 < active.size(); i++) {
+            drawTriangle(renderBuffer, screenPoints[0], screenPoints[i], screenPoints[i + 1],
+                    cameraZ[0], cameraZ[i], cameraZ[i + 1],
+                    mouseInteractionController, paintColor);
+        }
+    }
+
+    /**
+     * Thread-local storage for per-vertex camera-space z during rendering.
+     */
+    private static final ThreadLocal<double[]> CAMERA_Z = new ThreadLocal<>();
+
+    /**
+     * Gets a thread-local camera-z array sized for the given number of vertices.
+     *
+     * @param size the required array size
+     * @return a thread-local double array
+     */
+    private double[] getCameraZ(final int size) {
+        double[] cameraZ = CAMERA_Z.get();
+        if (cameraZ == null || cameraZ.length < size) {
+            cameraZ = new double[size];
+            CAMERA_Z.set(cameraZ);
+        }
+        return cameraZ;
+    }
+
+    /**
+     * Gets a thread-local screen points array sized for the given number of vertices.
+     *
+     * @param size the required array size
+     * @return a thread-local Point2D array
+     */
+    private Point2D[] getScreenPoints(final int size) {
+        Point2D[] screenPoints = SCREEN_POINTS.get();
+        if (screenPoints == null || screenPoints.length < size) {
+            screenPoints = new Point2D[size];
+            SCREEN_POINTS.set(screenPoints);
+        }
+        return screenPoints;
+    }
+
+    /**
+     * Transforms vertices to screen space and computes lighting once per frame.
+     *
+     * <p>Overrides parent to add lighting computation during the single-threaded
+     * transform phase. This ensures lighting is calculated only once per polygon
+     * per frame, rather than once per render thread.</p>
+     *
+     * @param transforms       the transform stack to apply
+     * @param aggregator       the render aggregator to queue shapes into
+     * @param renderingContext the rendering context
+     */
+    @Override
+    public void transform(final TransformStack transforms,
+                          final RenderAggregator aggregator,
+                          final RenderingContext renderingContext) {
+        // Transform vertices to screen space
+        super.transform(transforms, aggregator, renderingContext);
+
+        // Compute lighting once during transform phase (single-threaded)
+        if (shadingEnabled && renderingContext.lightingManager != null) {
+            calculateCenter(cachedCenter);
+            // Compute normal from first 3 vertices
+            Plane.computeNormal(
+                    vertices.get(0).coordinate,
+                    vertices.get(1).coordinate,
+                    vertices.get(2).coordinate,
+                    cachedNormal
+            );
+            renderingContext.lightingManager.computeLighting(
+                    this, cachedCenter, cachedNormal, color, shadedColor);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java
new file mode 100644 (file)
index 0000000..80d976e
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Solid-color polygon rendering with scanline rasterization.
+ *
+ * <p>SolidPolygon is the unified polygon type for both rendering and CSG operations.
+ * It supports N vertices (N >= 3) and handles perspective-correct interpolation,
+ * alpha blending, viewport clipping, backface culling, and optional flat shading.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon} - Unified polygon for rendering and CSG</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator} - Edge interpolation for scanlines</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java
new file mode 100644 (file)
index 0000000..d62fa3b
--- /dev/null
@@ -0,0 +1,124 @@
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+
+/**
+ * Paint/sort handle for one triangle of a {@link TriangleMeshBlock}. The
+ * triangle's geometry lives in the block's flat arrays (SoA layout); this
+ * object exists only so the existing sort/bin/paint pipeline — typed on
+ * individual shapes — can address one triangle. Handles are allocated once
+ * at block build time and reused every frame; {@link #transform} is a
+ * no-op because the block transforms all its triangles in one tight loop.
+ *
+ * <p>Per-slot screen data is read from the block's arrays through the
+ * overridden accessors, so the Z-comparator and tile binning see exactly
+ * the values an object-backed {@link TexturedTriangle} would expose.</p>
+ */
+final class MeshTriangle extends TexturedTriangle {
+
+    /**
+     * Scratch Point2D carriers for the flat paint call. Interpolators
+     * hold references to the screen/UV points they are given, so the
+     * objects must stay stable for the duration of one paint — but a
+     * paint never nests, so three screen + three UV points per thread
+     * suffice and nothing is allocated per triangle.
+     */
+    private static final ThreadLocal<Point2D[]> SCREEN_SCRATCH =
+            ThreadLocal.withInitial(() -> new Point2D[]{
+                    new Point2D(), new Point2D(), new Point2D()});
+    private static final ThreadLocal<Point2D[]> UV_SCRATCH =
+            ThreadLocal.withInitial(() -> new Point2D[]{
+                    new Point2D(), new Point2D(), new Point2D()});
+
+    private final TriangleMeshBlock block;
+    private final int index;
+
+    MeshTriangle(final TriangleMeshBlock block, final int index,
+                 final eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture texture) {
+        super(texture);
+        this.block = block;
+        this.index = index;
+    }
+
+    /**
+     * No-op: the owning block transforms all its triangles (including this
+     * one) in a single flat-array loop. Queued handles are never
+     * transformed through the shape path. Sort Z and screen bounds live in
+     * the inherited per-slot fields, written by the block via
+     * {@code setSlotScreenState}.
+     */
+    @Override
+    public void transform(final TransformStack transforms,
+                          final RenderAggregator aggregator,
+                          final RenderingContext renderingContext) {
+        // intentionally empty — see TriangleMeshBlock.transform
+    }
+
+    /**
+     * Publishes this triangle's per-slot screen state (called by the
+     * owning block during its flat transform loop). Trampoline: the
+     * inherited setter is protected, so the call must go through the
+     * subclass.
+     */
+    void publishSlotState(final int slot, final double z,
+                          final double minY, final double maxY,
+                          final double minX, final double maxX) {
+        setSlotScreenState(slot, z, minY, maxY, minX, maxX);
+    }
+
+    @Override
+    public void paint(final RenderingContext renderBuffer) {
+        final int slot = renderBuffer.vertexSlot;
+        final Point2D[] screen = SCREEN_SCRATCH.get();
+        final Point2D[] uvs = UV_SCRATCH.get();
+
+        final int clip = block.clipOffset(slot, index);
+        if (clip >= 0) {
+            // Near-plane straddler: the block clipped to a loop of 3-4
+            // vertices, stored as (x, y, z, u, v, sx, sy) tuples. Paint
+            // as a fan, mirroring TexturedTriangle.paint's clipped path.
+            final int count = block.clipCount(slot, index);
+            final double[] store = block.clipStore(slot);
+            loadClipVertex(screen[0], uvs[0], store, clip);
+            for (int i = 1; i + 1 < count; i++) {
+                loadClipVertex(screen[1], uvs[1], store, clip + i * 7);
+                loadClipVertex(screen[2], uvs[2], store, clip + (i + 1) * 7);
+                paintFlat(renderBuffer, block.texture(index), block.backfaceCull(),
+                        screen[0], screen[1], screen[2],
+                        uvs[0], uvs[1], uvs[2],
+                        store[clip + 2],
+                        store[clip + i * 7 + 2],
+                        store[clip + (i + 1) * 7 + 2],
+                        block.clipTtd(slot, clip));
+            }
+            return;
+        }
+
+        block.loadScreenVertex(screen[0], uvs[0], slot, index, 0, renderBuffer);
+        block.loadScreenVertex(screen[1], uvs[1], slot, index, 1, renderBuffer);
+        block.loadScreenVertex(screen[2], uvs[2], slot, index, 2, renderBuffer);
+        paintFlat(renderBuffer, block.texture(index), block.backfaceCull(),
+                screen[0], screen[1], screen[2],
+                uvs[0], uvs[1], uvs[2],
+                block.camZ(slot, index, 0),
+                block.camZ(slot, index, 1),
+                block.camZ(slot, index, 2),
+                block.origTtd(index));
+    }
+
+    /**
+     * Fills the scratch screen/UV points from one clip-loop entry; screen
+     * coordinates were stored at clip time with the exact
+     * {@code Vertex.setCameraSpaceCoordinate} expression.
+     */
+    private void loadClipVertex(final Point2D screen, final Point2D uv,
+                                final double[] store, final int entry) {
+        screen.x = store[entry + 5];
+        screen.y = store[entry + 6];
+        uv.x = store[entry + 3];
+        uv.y = store[entry + 4];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PerspectiveBorderInterpolator.java
new file mode 100644 (file)
index 0000000..79d39cb
--- /dev/null
@@ -0,0 +1,164 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+
+import static java.lang.Math.round;
+
+/**
+ * Border interpolator carrying perspective-corrected texture gradients.
+ *
+ * <p>Quake-style perspective texturing: instead of interpolating texture
+ * coordinates (u, v) linearly in screen space (affine — wrong except for
+ * face-on triangles), interpolates (u/z, v/z, 1/z) which ARE linear in
+ * screen space. The scanline renderer recovers exact (u, v) with one
+ * reciprocal every N pixels and steps affinely in between, so the divide
+ * cost is amortized.</p>
+ *
+ * <p>The u/v values handed to this interpolator must already be premultiplied
+ * by the mipmap's multiplication factor and divided by the vertex's
+ * camera-space z; w values are 1/z.</p>
+ *
+ * @see PolygonBorderInterpolator
+ * @see TexturedTriangle
+ */
+public class PerspectiveBorderInterpolator {
+
+    /**
+     * Small epsilon value for comparing near-zero heights to detect horizontal edges.
+     */
+    private static final double EPSILON = 0.0001;
+
+    /** The first endpoint of this edge in screen space. */
+    private Point2D p1;
+    /** The second endpoint of this edge in screen space. */
+    private Point2D p2;
+
+    /** The vertical span (p2.y - p1.y) in double precision, may be negative. */
+    private double height;
+    /** The horizontal span (p2.x - p1.x) in double precision, may be negative. */
+    private double width;
+
+    /** u/z at the endpoints (mipmap factor premultiplied). */
+    private double su1, su2;
+    /** v/z at the endpoints (mipmap factor premultiplied). */
+    private double sv1, sv2;
+    /** 1/z at the endpoints. */
+    private double sw1, sw2;
+
+    /** Spans along the edge. */
+    private double suSpan, svSpan, swSpan;
+
+    /**
+     * Biased 1/z (the depth-buffer quantity) at the endpoints. Linear in
+     * screen space exactly like sw, so it rides the same edge
+     * interpolation; set only in z-buffer mode via {@link #setPointsZW}.
+     */
+    private double zw1, zw2;
+    private double zwSpan;
+
+    /** The current Y coordinate being interpolated. */
+    private int currentY;
+
+    /**
+     * Sets the screen endpoints and perspective-corrected gradients for this edge.
+     *
+     * @param screenPoint1 the first screen-space endpoint
+     * @param screenPoint2 the second screen-space endpoint
+     * @param su1          u/z at the first endpoint (mipmap factor premultiplied)
+     * @param sv1          v/z at the first endpoint (mipmap factor premultiplied)
+     * @param sw1          1/z at the first endpoint
+     * @param su2          u/z at the second endpoint
+     * @param sv2          v/z at the second endpoint
+     * @param sw2          1/z at the second endpoint
+     */
+    public void setPoints(final Point2D screenPoint1, final Point2D screenPoint2,
+                          final double su1, final double sv1, final double sw1,
+                          final double su2, final double sv2, final double sw2) {
+        this.p1 = screenPoint1;
+        this.p2 = screenPoint2;
+        this.su1 = su1;
+        this.sv1 = sv1;
+        this.sw1 = sw1;
+        this.su2 = su2;
+        this.sv2 = sv2;
+        this.sw2 = sw2;
+
+        height = p2.y - p1.y;
+        width = p2.x - p1.x;
+
+        suSpan = su2 - su1;
+        svSpan = sv2 - sv1;
+        swSpan = sw2 - sw1;
+    }
+
+    /**
+     * Tests whether the given y coordinate falls within the vertical span of this edge.
+     */
+    public boolean containsY(final int y) {
+        final double minY = Math.min(p1.y, p2.y);
+        final double maxY = Math.max(p1.y, p2.y);
+        return y >= minY && y <= maxY;
+    }
+
+    private double interpolationT() {
+        return (currentY - p1.y) / height;
+    }
+
+    /** Returns interpolated u/z at the current Y. */
+    public double getSU() {
+        if (Math.abs(height) < EPSILON)
+            return (su1 + su2) / 2d;
+        return su1 + interpolationT() * suSpan;
+    }
+
+    /** Returns interpolated v/z at the current Y. */
+    public double getSV() {
+        if (Math.abs(height) < EPSILON)
+            return (sv1 + sv2) / 2d;
+        return sv1 + interpolationT() * svSpan;
+    }
+
+    /** Returns interpolated 1/z at the current Y. */
+    public double getSW() {
+        if (Math.abs(height) < EPSILON)
+            return (sw1 + sw2) / 2d;
+        return sw1 + interpolationT() * swSpan;
+    }
+
+    /**
+     * Sets the depth-buffer endpoint values (biased 1/z) for this edge.
+     * Companion to {@link #setPoints}: kept separate so the classic
+     * painter path never pays for the extra channel.
+     */
+    public void setPointsZW(final double zw1, final double zw2) {
+        this.zw1 = zw1;
+        this.zw2 = zw2;
+        zwSpan = zw2 - zw1;
+    }
+
+    /** Returns interpolated biased 1/z (depth value) at the current Y. */
+    public double getZW() {
+        if (Math.abs(height) < EPSILON)
+            return (zw1 + zw2) / 2d;
+        return zw1 + interpolationT() * zwSpan;
+    }
+
+    /**
+     * Computes the interpolated x coordinate rounded to the nearest integer.
+     * Identical semantics to {@link PolygonBorderInterpolator#getX()}.
+     */
+    public int getX() {
+        if (Math.abs(height) < EPSILON)
+            return (int) round((p1.x + p2.x) / 2);
+        return (int) round(p1.x + (width * (currentY - p1.y)) / height);
+    }
+
+    /** Sets the current Y coordinate for interpolation. */
+    public void setCurrentY(final int y) {
+        this.currentY = y;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java
new file mode 100644 (file)
index 0000000..d3f0893
--- /dev/null
@@ -0,0 +1,196 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+
+import static java.lang.Math.round;
+
+/**
+ * Interpolator for textured polygon edges with perspective correction.
+ *
+ * <p>Maps screen coordinates to texture coordinates while maintaining
+ * perspective accuracy. Uses double-precision arithmetic to eliminate
+ * T-junction gaps from truncation errors, matching {@code LineInterpolator}
+ * behavior in the solid polygon renderer.</p>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator
+ */
+public class PolygonBorderInterpolator {
+
+    /**
+     * Small epsilon value for comparing near-zero heights to detect horizontal edges.
+     */
+    private static final double EPSILON = 0.0001;
+
+    /**
+     * The first endpoint of this edge in screen space.
+     */
+    Point2D p1;
+    /**
+     * The second endpoint of this edge in screen space.
+     */
+    Point2D p2;
+
+    /**
+     * The vertical span (p2.y - p1.y) in double precision, which may be negative.
+     *
+     * <p>Stored as double to preserve subpixel precision during interpolation,
+     * eliminating rounding errors that cause T-junction gaps.</p>
+     */
+    private double height;
+    /**
+     * The horizontal span (p2.x - p1.x) in double precision, which may be negative.
+     */
+    private double width;
+
+    /**
+     * The texture coordinate at the first endpoint.
+     */
+    private Point2D texturePoint1;
+    /**
+     * The texture coordinate at the second endpoint.
+     */
+    private Point2D texturePoint2;
+    /**
+     * The texture U span (texturePoint2.x - texturePoint1.x).
+     */
+    private double textureWidth;
+    /**
+     * The texture V span (texturePoint2.y - texturePoint1.y).
+     */
+    private double textureHeight;
+
+    /**
+     * The current Y coordinate being interpolated, used for computing texture coordinates.
+     */
+    private int currentY;
+
+    /**
+     * Creates a new polygon border interpolator.
+     */
+    public PolygonBorderInterpolator() {
+    }
+
+    /**
+     * Tests whether the given y coordinate falls within the vertical span of this edge.
+     *
+     * <p>Uses double-precision comparison to handle subpixel vertex positions correctly.</p>
+     *
+     * @param y the scanline y coordinate to test
+     * @return {@code true} if {@code y} is between the y coordinates of the two endpoints (inclusive)
+     */
+    public boolean containsY(final int y) {
+        final double minY = Math.min(p1.y, p2.y);
+        final double maxY = Math.max(p1.y, p2.y);
+        return y >= minY && y <= maxY;
+    }
+
+    /**
+     * Returns the interpolated texture X coordinate at the current Y position.
+     *
+     * <p>For horizontal edges (height near zero), returns the midpoint texture X.</p>
+     *
+     * @return the texture X coordinate
+     */
+    public double getTX() {
+        if (Math.abs(height) < EPSILON) {
+            return (texturePoint1.x + texturePoint2.x) / 2d;
+        }
+        final double t = (currentY - p1.y) / height;
+        return texturePoint1.x + t * textureWidth;
+    }
+
+    /**
+     * Returns the interpolated texture Y coordinate at the current Y position.
+     *
+     * <p>For horizontal edges (height near zero), returns the midpoint texture Y.</p>
+     *
+     * @return the texture Y coordinate
+     */
+    public double getTY() {
+        if (Math.abs(height) < EPSILON) {
+            return (texturePoint1.y + texturePoint2.y) / 2d;
+        }
+        final double t = (currentY - p1.y) / height;
+        return texturePoint1.y + t * textureHeight;
+    }
+
+    /**
+     * Computes the interpolated x coordinate rounded to the nearest integer.
+     *
+     * <p>For horizontal edges (height near zero), returns the midpoint x value
+     * to avoid division by zero.</p>
+     *
+     * @return the interpolated x coordinate rounded to the nearest integer
+     */
+    public int getX() {
+        if (Math.abs(height) < EPSILON) {
+            return (int) round((p1.x + p2.x) / 2);
+        }
+        return (int) round(p1.x + (width * (currentY - p1.y)) / height);
+    }
+
+    /**
+     * Sets the current Y coordinate for interpolation.
+     *
+     * @param y the current Y coordinate
+     */
+    public void setCurrentY(final int y) {
+        this.currentY = y;
+    }
+
+    /**
+     * Sets the screen and texture coordinates for this edge.
+     *
+     * <p>Screen coordinates are stored directly as references. Callers should
+     * ensure coordinates are not modified during rendering for thread safety.</p>
+     *
+     * @param screenPoint1   the first screen-space endpoint
+     * @param screenPoint2   the second screen-space endpoint
+     * @param texturePoint1  the texture coordinate for the first endpoint
+     * @param texturePoint2  the texture coordinate for the second endpoint
+     */
+    public void setPoints(final Point2D screenPoint1, final Point2D screenPoint2,
+                          final Point2D texturePoint1, final Point2D texturePoint2) {
+
+        this.p1 = screenPoint1;
+        this.p2 = screenPoint2;
+        this.texturePoint1 = texturePoint1;
+        this.texturePoint2 = texturePoint2;
+
+        height = p2.y - p1.y;
+        width = p2.x - p1.x;
+
+        textureWidth = texturePoint2.x - texturePoint1.x;
+        textureHeight = texturePoint2.y - texturePoint1.y;
+    }
+
+    /**
+     * Biased 1/z (the depth-buffer quantity) at the endpoints; set only
+     * in z-buffer mode via {@link #setPointsZW}. Interpolated linearly
+     * along the edge like the texture coordinates.
+     */
+    private double zw1, zw2, zwSpan;
+
+    /**
+     * Sets the depth-buffer endpoint values (biased 1/z) for this edge.
+     * Companion to {@link #setPoints}.
+     */
+    public void setPointsZW(final double zw1, final double zw2) {
+        this.zw1 = zw1;
+        this.zw2 = zw2;
+        zwSpan = zw2 - zw1;
+    }
+
+    /** Returns interpolated biased 1/z (depth value) at the current Y. */
+    public double getZW() {
+        if (Math.abs(height) < EPSILON)
+            return (zw1 + zw2) / 2d;
+        final double t = (currentY - p1.y) / height;
+        return zw1 + t * zwSpan;
+    }
+
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java
new file mode 100644 (file)
index 0000000..fb57245
--- /dev/null
@@ -0,0 +1,1475 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap;
+
+import java.awt.*;
+import java.util.List;
+
+import static eu.svjatoslav.aukio.e3d.geometry.Polygon.pointWithinPolygon;
+
+/**
+ * A textured triangle renderer with perspective-correct texture mapping.
+ *
+ * <p>Quake-style subdivided perspective correction: (u/z, v/z, 1/z) are
+ * interpolated linearly in screen space and the exact texture coordinate
+ * is recovered with one reciprocal per subdivision interval, with affine
+ * stepping in between. This makes screen-size tessellation unnecessary —
+ * triangles of any size render with correct perspective.</p>
+ *
+ * @see Texture
+ * @see Vertex#textureCoordinate
+ */
+public class TexturedTriangle extends AbstractCoordinateShape {
+
+    private static final ThreadLocal<PolygonBorderInterpolator[]> INTERPOLATORS =
+            ThreadLocal.withInitial(() -> new PolygonBorderInterpolator[]{
+                    new PolygonBorderInterpolator(), new PolygonBorderInterpolator(), new PolygonBorderInterpolator()
+            });
+
+    private static final ThreadLocal<PerspectiveBorderInterpolator[]> PERSPECTIVE_INTERPOLATORS =
+            ThreadLocal.withInitial(() -> new PerspectiveBorderInterpolator[]{
+                    new PerspectiveBorderInterpolator(), new PerspectiveBorderInterpolator(),
+                    new PerspectiveBorderInterpolator()
+            });
+
+    /**
+     * Quake-style perspective correction interval: the exact texture
+     * coordinate (one reciprocal) is computed every this many pixels;
+     * between correction points the scanline steps affinely.
+     */
+    private static final int PERSPECTIVE_CORRECTION_INTERVAL = 16;
+
+    /**
+     * Minimum camera-space z for the perspective path. Triangles with any
+     * vertex closer than this fall back to the affine path (they straddle
+     * the near plane, where 1/z interpolation is invalid).
+     */
+    private static final double PERSPECTIVE_MIN_Z = 0.001;
+
+    /** A/B tuning knobs for the SDF path, see paintSdf. */
+    private static final double SDF_GAMMA =
+            Double.parseDouble(System.getProperty("e3d.sdf.gamma", "0"));
+    private static final double SDF_SHARPEN =
+            Double.parseDouble(System.getProperty("e3d.sdf.sharpen", "2"));
+    /** Debug: print SDF path decisions when they change. */
+    private static final boolean SDF_DEBUG = Boolean.getBoolean("e3d.sdf.debug");
+    private static boolean sdfDebugLastPerspective;
+    private static double sdfDebugLastFootY = -1;
+
+    /**
+     * When true (default), textured triangles render with Quake-style
+     * perspective-correct texture mapping.
+     * Volatile: consulted from parallel paint workers.
+     */
+    private static volatile boolean perspectiveCorrectionEnabled = true;
+
+    /**
+     * Enables or disables perspective-correct texture mapping.
+     * When disabled, rendering falls back to plain affine mapping, which
+     * visibly warps textures on large on-screen triangles at steep angles.
+     *
+     * @param enabled {@code true} for perspective-correct mapping
+     */
+    public static void setPerspectiveCorrectionEnabled(final boolean enabled) {
+        perspectiveCorrectionEnabled = enabled;
+    }
+
+    /**
+     * Returns whether perspective-correct texture mapping is enabled.
+     *
+     * @return {@code true} when perspective correction is active
+     */
+    public static boolean isPerspectiveCorrectionEnabled() {
+        return perspectiveCorrectionEnabled;
+    }
+
+    /**
+     * The texture to apply to this triangle.
+     * Volatile: the global illumination system swaps the premultiplied
+     * lightmap composite on GI threads while paint workers read it.
+     * Read once per paint call so a triangle never shows a half-updated
+     * texture.
+     */
+    private volatile Texture texture;
+
+    /**
+     * Returns the current texture.
+     *
+     * @return the texture
+     */
+    public Texture getTexture() {
+        return texture;
+    }
+
+    /**
+     * Atomically swaps the texture. Painters pick up the new texture at the
+     * next paint call; a triangle in flight finishes with the old one.
+     *
+     * @param texture the new texture
+     */
+    public void setTexture(final Texture texture) {
+        this.texture = texture;
+    }
+
+    private boolean backfaceCulling = Boolean.getBoolean("e3d.backface");
+
+    // --- ad-hoc frame profiling (-De3d.prof=true; dead code when off)
+    /** Master switch, constant-folded when false. */
+    private static final boolean PROF =
+            Boolean.getBoolean("e3d.prof");
+    /** paintTriangle invocations. */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_TRIS = new java.util.concurrent.atomic.AtomicLong();
+    /** Triangles facing away (engine winding convention). */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_BACKFACE = new java.util.concurrent.atomic.AtomicLong();
+    /** Triangles discarded by vertical render-bounds clamp. */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_OFFY = new java.util.concurrent.atomic.AtomicLong();
+    /** Triangles with screen bounding box below 2x2 pixels. */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_TINY = new java.util.concurrent.atomic.AtomicLong();
+    /** Scanline spans drawn. */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_SPANS = new java.util.concurrent.atomic.AtomicLong();
+    /** Pixel loop iterations across all spans. */
+    public static final java.util.concurrent.atomic.AtomicLong
+            PROF_PIXELS = new java.util.concurrent.atomic.AtomicLong();
+
+    /** Resets all profiling counters. */
+    public static void profReset() {
+        PROF_TRIS.set(0);
+        PROF_BACKFACE.set(0);
+        PROF_OFFY.set(0);
+        PROF_TINY.set(0);
+        PROF_SPANS.set(0);
+        PROF_PIXELS.set(0);
+    }
+
+    /**
+     * Total UV distance between all texture coordinate pairs.
+     * Computed at construction time to determine appropriate mipmap level.
+     */
+    private double totalTextureDistance;
+
+    /**
+     * Creates a textured triangle with the specified vertices and texture.
+     *
+     * @param p1      the first vertex (must have textureCoordinate set)
+     * @param p2      the second vertex (must have textureCoordinate set)
+     * @param p3      the third vertex (must have textureCoordinate set)
+     * @param texture the texture to apply
+     */
+    public TexturedTriangle(Vertex p1, Vertex p2, Vertex p3, final Texture texture) {
+
+        super(p1, p2, p3);
+        this.texture = texture;
+        computeTotalTextureDistance();
+    }
+
+    /**
+     * Constructor for flat-array-backed subclasses ({@code MeshTriangle})
+     * that carry no {@link Vertex} objects and paint exclusively through
+     * {@link #paintFlat}, which computes the mipmap metric inline.
+     * {@code totalTextureDistance} is set to a neutral 1 — never read on
+     * that path.
+     *
+     * @param texture the texture the subclass paints with
+     */
+    protected TexturedTriangle(final Texture texture) {
+        super(0);
+        this.texture = texture;
+        this.totalTextureDistance = 1;
+    }
+
+    /**
+     * Computes the total UV distance between all texture coordinate pairs.
+     * Used to determine appropriate mipmap level.
+     */
+    private void computeTotalTextureDistance() {
+        totalTextureDistance = vertices.get(0).textureCoordinate.getDistanceTo(vertices.get(1).textureCoordinate);
+        totalTextureDistance += vertices.get(0).textureCoordinate.getDistanceTo(vertices.get(2).textureCoordinate);
+        totalTextureDistance += vertices.get(1).textureCoordinate.getDistanceTo(vertices.get(2).textureCoordinate);
+    }
+
+    /**
+     * Recomputes the mipmap-selection metric after texture coordinates are
+     * replaced post-construction (used by generated-UV subclasses like
+     * lightmapped triangles).
+     */
+    protected final void refreshTextureDistance() {
+        computeTotalTextureDistance();
+    }
+
+    /**
+     * Z-buffer variant of {@link #drawHorizontalLinePerspective}: the
+     * biased 1/z endpoint values ride the interpolators' zw channel, and
+     * every pixel is depth-tested BEFORE the texture fetch — rejected
+     * pixels cost one float compare instead of a texel read. Opaque
+     * texels (alpha 255) write depth; blended texels write color only,
+     * so translucency never occludes. Requires
+     * {@code renderBuffer.depth != null} and {@code setPointsZW} called
+     * on both interpolators.
+     */
+    private void drawHorizontalLinePerspectiveZ(
+            final PerspectiveBorderInterpolator line1,
+            final PerspectiveBorderInterpolator line2,
+            final int y,
+            final RenderingContext renderBuffer,
+            final TextureBitmap textureBitmap) {
+
+        line1.setCurrentY(y);
+        line2.setCurrentY(y);
+
+        int x1 = line1.getX();
+        int x2 = line2.getX();
+
+        final double su1, sv1, sw1, zw1;
+        final double su2, sv2, sw2, zw2;
+
+        if (x1 <= x2) {
+            su1 = line1.getSU();
+            sv1 = line1.getSV();
+            sw1 = line1.getSW();
+            zw1 = line1.getZW();
+            su2 = line2.getSU();
+            sv2 = line2.getSV();
+            sw2 = line2.getSW();
+            zw2 = line2.getZW();
+        } else {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+            su1 = line2.getSU();
+            sv1 = line2.getSV();
+            sw1 = line2.getSW();
+            zw1 = line2.getZW();
+            su2 = line1.getSU();
+            sv2 = line1.getSV();
+            sw2 = line1.getSW();
+            zw2 = line1.getZW();
+        }
+
+        final double realWidth = x2 - x1;
+        final double realX1 = x1;
+
+        if (x1 < renderBuffer.renderMinX)
+            x1 = renderBuffer.renderMinX;
+        if (x2 >= renderBuffer.renderMaxX)
+            x2 = renderBuffer.renderMaxX;
+
+        final int span = x2 - x1;
+        if (span <= 0)
+            return;
+
+        if (PROF) {
+            PROF_SPANS.incrementAndGet();
+            PROF_PIXELS.addAndGet(span);
+        }
+
+        int renderBufferOffset = (y * renderBuffer.width) + x1;
+
+        final double dsu = (su2 - su1) / realWidth;
+        final double dsv = (sv2 - sv1) / realWidth;
+        final double dsw = (sw2 - sw1) / realWidth;
+        final double dzw = (zw2 - zw1) / realWidth;
+
+        // Depth margin (polygon offset): fragments within dzMargin world
+        // units of the stored depth resolve coherently instead of
+        // z-fighting per pixel — near-coplanar surface pairs (kit-bashed
+        // wall pieces, draped decals, LOD shells). The queue is
+        // back-to-front (painter, Z descending), so WITHIN the window
+        // the LATER (nearer) writer must win: the test therefore rejects
+        // only fragments that are BEHIND the stored depth by more than
+        // the margin. (The previous "+margin" form made the FIRST —
+        // i.e. FARTHER — writer win the window, so dirt within margin
+        // below the road beat the pavement; combined with a per-span
+        // margin constant that inflates by (z_pixel/z_near)^2 down
+        // grazing spans, ground leaked through the road at near-horizon
+        // pitches. Fixed camera, view-dependent holes = impossible for
+        // a correct z-buffer.)
+        // The w-space margin is dz*w^2 evaluated PER PIXEL at the
+        // fragment's own depth.
+
+        double su = su1 + dsu * (x1 - realX1);
+        double sv = sv1 + dsv * (x1 - realX1);
+        double sw = sw1 + dsw * (x1 - realX1);
+        double zw = zw1 + dzw * (x1 - realX1);
+
+        final int[] texPixels = textureBitmap.pixels;
+        final int texW = textureBitmap.width;
+        final int texH = textureBitmap.height;
+        final int texWMinus1 = texW - 1;
+        final int texHMinus1 = texH - 1;
+        final int[] renderBufferPixels = renderBuffer.pixels;
+        final float[] depth = renderBuffer.depth;
+        // Alpha pass (depthPass 2): depth-test but never depth-write,
+        // so cutout foliage cannot occlude later fragments
+        final boolean writeDepth = renderBuffer.depthPass != 2;
+        // see drawHorizontalLine: null texture (unit tests) = clamp
+        final boolean wrap = texture != null && texture.wrap;
+
+        // Same adaptive-subdivision ladder as the painter variant
+        final double ue1 = su1 / sw1;
+        final double ue2 = su2 / sw2;
+        final double ve1 = sv1 / sw1;
+        final double ve2 = sv2 / sw2;
+        final double wRatio = Math.max(sw1, sw2) / Math.min(sw1, sw2);
+        final double texelRate = Math.max(Math.abs(ue2 - ue1), Math.abs(ve2 - ve1))
+                / realWidth * wRatio;
+        final double k = Math.abs(dsw) / Math.min(sw1, sw2);
+        final double curvature = texelRate * k;
+        final int interval = curvature < 0.5 / (16 * 16) ? PERSPECTIVE_CORRECTION_INTERVAL
+                : curvature < 0.5 / (8 * 8) ? 8
+                : curvature < 0.5 / (4 * 4) ? 4
+                : curvature < 0.5 / (2 * 2) ? 2 : 1;
+        final double invInterval = 1d / interval;
+
+        int done = 0;
+        double invW = 1d / sw;
+        double tx = su * invW;
+        double ty = sv * invW;
+        while (done < span) {
+            final int block = Math.min(interval, span - done);
+
+            su += dsu * block;
+            sv += dsv * block;
+            sw += dsw * block;
+            final double invWNext = 1d / sw;
+            final double txNext = su * invWNext;
+            final double tyNext = sv * invWNext;
+
+            final double invBlock = block == interval ? invInterval : 1d / block;
+            final double txStep = (txNext - tx) * invBlock;
+            final double tyStep = (tyNext - ty) * invBlock;
+
+            for (int i = 0; i < block; i++) {
+                if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                    int itx = (int) tx;
+                    int ity = (int) ty;
+
+                    if (wrap) {
+                        itx = Math.floorMod(itx, texW);
+                        ity = Math.floorMod(ity, texH);
+                    } else {
+                        if (itx < 0) itx = 0;
+                        else if (itx > texWMinus1) itx = texWMinus1;
+
+                        if (ity < 0) ity = 0;
+                        else if (ity > texHMinus1) ity = texHMinus1;
+                    }
+
+                    final int srcPixel = texPixels[ity * texW + itx];
+                    final int srcAlpha = (srcPixel >> 24) & 0xff;
+
+                    if (srcAlpha == 255) {
+                        renderBufferPixels[renderBufferOffset] = srcPixel;
+                        if (writeDepth)
+                            depth[renderBufferOffset] = (float) zw;
+                    } else if (srcAlpha != 0) {
+                        // Translucent: blend, but do NOT write depth —
+                        // translucency must not occlude later fragments
+                        final int destPixel = renderBufferPixels[renderBufferOffset];
+                        final int destR = (destPixel >> 16) & 0xff;
+                        final int destG = (destPixel >> 8) & 0xff;
+                        final int destB = destPixel & 0xff;
+
+                        final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+                        final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+                        final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+
+                        renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+                    }
+                }
+
+                tx += txStep;
+                ty += tyStep;
+                zw += dzw;
+                renderBufferOffset++;
+            }
+
+            tx = txNext;
+            ty = tyNext;
+
+            done += block;
+        }
+    }
+
+    @Override
+    public void paint(final RenderingContext renderBuffer) {
+        // Near-plane clip output takes precedence: a straddling triangle
+        // clips to a triangle or a quad (one corner cut off). The quad is
+        // painted as a 2-triangle fan — the clip of a convex polygon stays
+        // convex, so fan triangulation is exact. Clipped vertices carry
+        // UVs interpolated in 3D at the cut, which is exactly what the
+        // perspective-correct path expects of a point on the edge.
+        final List<Vertex> clipped = clippedVertices(renderBuffer);
+        if (clipped == null) {
+            paintTriangle(renderBuffer, vertices.get(0), vertices.get(1), vertices.get(2));
+            return;
+        }
+        for (int i = 1; i + 1 < clipped.size(); i++) {
+            paintTriangle(renderBuffer, clipped.get(0), clipped.get(i), clipped.get(i + 1));
+        }
+    }
+
+    /**
+     * Renders one textured triangle defined by the given vertices (either
+     * the shape's own three, or a fan triple from the near-plane-clipped
+     * loop).
+     *
+     * <p>This method performs:</p>
+     * <ul>
+     *   <li>Backface culling check (if enabled)</li>
+     *   <li>Mouse interaction detection</li>
+     *   <li>Mipmap level selection based on screen coverage</li>
+     *   <li>Scanline rasterization with texture sampling</li>
+     * </ul>
+     *
+     * @param renderBuffer the rendering context containing the pixel buffer
+     * @param v1           the first triangle vertex
+     * @param v2           the second triangle vertex
+     * @param v3           the third triangle vertex
+     */
+    private void paintTriangle(final RenderingContext renderBuffer,
+                               final Vertex v1, final Vertex v2, final Vertex v3) {
+
+        final Point2D projectedPoint1 = v1.onScreenCoordinate(renderBuffer);
+        final Point2D projectedPoint2 = v2.onScreenCoordinate(renderBuffer);
+        final Point2D projectedPoint3 = v3.onScreenCoordinate(renderBuffer);
+
+        if (mouseInteractionController != null)
+            if (renderBuffer.getMouseEvent() != null)
+                if (pointWithinPolygon(
+                        renderBuffer.getMouseEvent().coordinate, projectedPoint1, projectedPoint2, projectedPoint3)) {
+                    final double[] uv = textureCoordinateAt(
+                            renderBuffer.getMouseEvent().coordinate,
+                            projectedPoint1, projectedPoint2, projectedPoint3,
+                            v1, v2, v3, renderBuffer);
+                    renderBuffer.setCurrentObjectUnderMouseCursor(
+                            mouseInteractionController, uv[0], uv[1]);
+                }
+
+        // Show polygon boundaries (for debugging)
+        if (renderBuffer.developerTools != null && renderBuffer.developerTools.showPolygonBorders)
+            showBorders(renderBuffer);
+
+        // Keep double precision to eliminate T-junction gaps from truncation errors
+        final double y1 = projectedPoint1.y;
+        final double y2 = projectedPoint2.y;
+        final double y3 = projectedPoint3.y;
+
+        // Find top-most point (use ceil to include all pixels triangle touches)
+        int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3)));
+        if (yTop < 0) yTop = 0;
+
+        // Find bottom-most point (use floor to include all pixels triangle touches)
+        int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3)));
+        if (yBottom >= renderBuffer.height) yBottom = renderBuffer.height - 1;
+
+        // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive)
+        yTop = Math.max(yTop, renderBuffer.renderMinY);
+        yBottom = Math.min(yBottom, renderBuffer.renderMaxY - 1);
+        if (yTop > yBottom) {
+            if (PROF)
+                PROF_OFFY.incrementAndGet();
+            return;
+        }
+
+        // Snapshot the texture reference for the whole paint: the GI system
+        // may swap the composite lightmap on another thread mid-frame.
+        final Texture texture = this.texture;
+
+        final double edge12 = projectedPoint1.getDistanceTo(projectedPoint2);
+        final double edge13 = projectedPoint1.getDistanceTo(projectedPoint3);
+        final double edge23 = projectedPoint2.getDistanceTo(projectedPoint3);
+        final double totalVisibleDistance = edge12 + edge13 + edge23;
+
+        final double scaleFactor = (totalVisibleDistance / totalTextureDistance) * 1.2d;
+
+        // SDF text/vector-art path: coverage comes from the distance
+        // field, not from stored coverage, so the mipmap chain (which
+        // trades sharpness for alias-freedom) is bypassed entirely.
+        if (texture.isSdf()) {
+            paintSdf(yTop, yBottom, renderBuffer,
+                    projectedPoint1, projectedPoint2, projectedPoint3, scaleFactor,
+                    v1, v2, v3);
+            return;
+        }
+
+        paintFlat(renderBuffer, texture, backfaceCulling,
+                projectedPoint1, projectedPoint2, projectedPoint3,
+                v1.textureCoordinate, v2.textureCoordinate, v3.textureCoordinate,
+                v1.transformedCoordinate(renderBuffer).z,
+                v2.transformedCoordinate(renderBuffer).z,
+                v3.transformedCoordinate(renderBuffer).z,
+                totalTextureDistance);
+    }
+
+    /**
+     * Shared rasterization core for one textured triangle whose vertices
+     * are already in screen space. Called both by the object-backed path
+     * ({@link #paintTriangle}) and by {@code TriangleMeshBlock} handles,
+     * whose vertex data lives in flat per-block arrays instead of
+     * {@link Vertex} objects — the math here is identical either way,
+     * keeping both paths bit-exact.
+     *
+     * <p>Mouse interaction and debug borders stay in the object path
+     * (mesh blocks do not support picking). SDF textures are rejected:
+     * mesh blocks never carry them (enforced at block build time).</p>
+     *
+     * @param renderBuffer     the rendering context containing the pixel buffer
+     * @param texture          the texture to sample
+     * @param backfaceCulling  whether to cull counter-clockwise triangles
+     * @param projectedPoint1  screen-space vertex 1
+     * @param projectedPoint2  screen-space vertex 2
+     * @param projectedPoint3  screen-space vertex 3
+     * @param texturePoint1    UV (primary-texture pixels) of vertex 1
+     * @param texturePoint2    UV of vertex 2
+     * @param texturePoint3    UV of vertex 3
+     * @param z1               camera-space depth of vertex 1
+     * @param z2               camera-space depth of vertex 2
+     * @param z3               camera-space depth of vertex 3
+     * @param totalTextureDistance UV perimeter for mipmap selection. For a
+     *                             near-plane-clipped fan this is the
+     *                             ORIGINAL triangle's perimeter (the clip
+     *                             does not change the texture's texel
+     *                             density), matching the object path.
+     */
+    void paintFlat(final RenderingContext renderBuffer,
+                   final Texture texture,
+                   final boolean backfaceCulling,
+                   final Point2D projectedPoint1,
+                   final Point2D projectedPoint2,
+                   final Point2D projectedPoint3,
+                   final Point2D texturePoint1,
+                   final Point2D texturePoint2,
+                   final Point2D texturePoint3,
+                   final double z1, final double z2, final double z3,
+                   final double totalTextureDistance) {
+
+        // Z-buffer two-pass classification: opaque-class triangles
+        // paint in pass 1 (depth test + write), alpha-class in pass 2
+        // (depth test, no write) — see RenderAggregator.paintSorted.
+        final boolean alphaClass = texture.isSdf() || texture.hasAlpha;
+        if ((renderBuffer.depthPass == 1) == alphaClass)
+            return;
+
+                   if (PROF) {
+            PROF_TRIS.incrementAndGet();
+            if ((projectedPoint2.x - projectedPoint1.x)
+                    * (projectedPoint3.y - projectedPoint1.y)
+                    - (projectedPoint3.x - projectedPoint1.x)
+                    * (projectedPoint2.y - projectedPoint1.y) >= 0)
+                PROF_BACKFACE.incrementAndGet();
+            final double bw = Math.max(projectedPoint1.x, Math.max(
+                    projectedPoint2.x, projectedPoint3.x))
+                    - Math.min(projectedPoint1.x, Math.min(
+                            projectedPoint2.x, projectedPoint3.x));
+            final double bh = Math.max(projectedPoint1.y, Math.max(
+                    projectedPoint2.y, projectedPoint3.y))
+                    - Math.min(projectedPoint1.y, Math.min(
+                            projectedPoint2.y, projectedPoint3.y));
+            if (bw * bh < 4)
+                PROF_TINY.incrementAndGet();
+        }
+
+        if (backfaceCulling) {
+            final double signedArea = (projectedPoint2.x - projectedPoint1.x)
+                    * (projectedPoint3.y - projectedPoint1.y)
+                    - (projectedPoint3.x - projectedPoint1.x)
+                    * (projectedPoint2.y - projectedPoint1.y);
+            if (signedArea >= 0)
+                return;
+        }
+
+        // Keep double precision to eliminate T-junction gaps from truncation errors
+        final double y1 = projectedPoint1.y;
+        final double y2 = projectedPoint2.y;
+        final double y3 = projectedPoint3.y;
+
+        // Find top-most point (use ceil to include all pixels triangle touches)
+        int yTop = (int) Math.ceil(Math.min(y1, Math.min(y2, y3)));
+        if (yTop < 0) yTop = 0;
+
+        // Find bottom-most point (use floor to include all pixels triangle touches)
+        int yBottom = (int) Math.floor(Math.max(y1, Math.max(y2, y3)));
+        if (yBottom >= renderBuffer.height) yBottom = renderBuffer.height - 1;
+
+        // Clamp to render Y bounds (use renderMaxY - 1 because loop is inclusive)
+        yTop = Math.max(yTop, renderBuffer.renderMinY);
+        yBottom = Math.min(yBottom, renderBuffer.renderMaxY - 1);
+        if (yTop > yBottom) {
+            if (PROF)
+                PROF_OFFY.incrementAndGet();
+            return;
+        }
+
+        if (texture.isSdf())
+            throw new IllegalStateException(
+                    "SDF textures are not supported in mesh blocks");
+
+        final double edge12 = projectedPoint1.getDistanceTo(projectedPoint2);
+        final double edge13 = projectedPoint1.getDistanceTo(projectedPoint3);
+        final double edge23 = projectedPoint2.getDistanceTo(projectedPoint3);
+        final double totalVisibleDistance = edge12 + edge13 + edge23;
+
+        final double scaleFactor = (totalVisibleDistance / totalTextureDistance) * 1.2d;
+
+        final TextureBitmap mipmap = texture.getMipmapForScale(scaleFactor);
+
+        if (perspectiveCorrectionEnabled) {
+            if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z && z3 > PERSPECTIVE_MIN_Z) {
+                // Affine mapping is within half a texel of exact
+                // perspective for small or nearly-flat triangles, making
+                // the perspective setup pointless for them: the midpoint
+                // error of affine vs exact is ~= texelSpan*(zRatio-1)/4
+                // where texelSpan is the texture range (in selected-mip
+                // texels) the triangle covers — NOT its pixel size (a
+                // triangle can map many texels into few pixels; measured
+                // 2026-09-06: a 4px span with a 56-texel range deviated 3
+                // texels under the pixel-size rule). Distant clusters of
+                // small triangles render affine.
+                // Verified by TexturedTrianglePerspectiveTest#affineWithinHalfTexelBound.
+                final double mf0 = mipmap.multiplicationFactor;
+                final double tu1 = texturePoint1.x * mf0;
+                final double tv1 = texturePoint1.y * mf0;
+                final double tu2 = texturePoint2.x * mf0;
+                final double tv2 = texturePoint2.y * mf0;
+                final double tu3 = texturePoint3.x * mf0;
+                final double tv3 = texturePoint3.y * mf0;
+                final double texelSpan = Math.max(
+                        Math.max(Math.abs(tu2 - tu1), Math.abs(tv2 - tv1)),
+                        Math.max(
+                                Math.max(Math.abs(tu3 - tu1), Math.abs(tv3 - tv1)),
+                                Math.max(Math.abs(tu3 - tu2), Math.abs(tv3 - tv2))));
+                final double zMin = Math.min(z1, Math.min(z2, z3));
+                final double zMax = Math.max(z1, Math.max(z2, z3));
+                if (texelSpan * (zMax / zMin - 1d) < 2d) {
+                    paintAffine(yTop, yBottom, mipmap, renderBuffer,
+                            projectedPoint1, projectedPoint2, projectedPoint3,
+                            texturePoint1, texturePoint2, texturePoint3,
+                            z1, z2, z3);
+                    return;
+                }
+
+                // Quake-style perspective-correct mapping: interpolate
+                // (u/z, v/z, 1/z), which are linear in screen space, and
+                // recover exact (u, v) every PERSPECTIVE_CORRECTION_INTERVAL
+                // pixels in the scanline. The mipmap multiplication factor
+                // is folded into the gradients here, so the scanline works
+                // directly in texture pixel units.
+                final double mf = mipmap.multiplicationFactor;
+
+                final double sw1 = 1d / z1;
+                final double sw2 = 1d / z2;
+                final double sw3 = 1d / z3;
+
+                final double su1 = texturePoint1.x * mf * sw1;
+                final double sv1 = texturePoint1.y * mf * sw1;
+                final double su2 = texturePoint2.x * mf * sw2;
+                final double sv2 = texturePoint2.y * mf * sw2;
+                final double su3 = texturePoint3.x * mf * sw3;
+                final double sv3 = texturePoint3.y * mf * sw3;
+
+                final PerspectiveBorderInterpolator[] pi = PERSPECTIVE_INTERPOLATORS.get();
+                pi[0].setPoints(projectedPoint1, projectedPoint2, su1, sv1, sw1, su2, sv2, sw2);
+                pi[1].setPoints(projectedPoint1, projectedPoint3, su1, sv1, sw1, su3, sv3, sw3);
+                pi[2].setPoints(projectedPoint2, projectedPoint3, su2, sv2, sw2, su3, sv3, sw3);
+
+                {
+                    // 1/z rides the same edge interpolation; spans
+                    // depth-test before texturing.
+                    final double zw1 = 1d / z1;
+                    final double zw2 = 1d / z2;
+                    final double zw3 = 1d / z3;
+                    pi[0].setPointsZW(zw1, zw2);
+                    pi[1].setPointsZW(zw1, zw3);
+                    pi[2].setPointsZW(zw2, zw3);
+                    for (int y = yTop; y <= yBottom; y++) {
+                        if (pi[0].containsY(y)) {
+                            if (pi[1].containsY(y))
+                                drawHorizontalLinePerspectiveZ(pi[0], pi[1], y, renderBuffer, mipmap);
+                            else if (pi[2].containsY(y))
+                                drawHorizontalLinePerspectiveZ(pi[0], pi[2], y, renderBuffer, mipmap);
+                        } else if (pi[1].containsY(y)) {
+                            if (pi[2].containsY(y))
+                                drawHorizontalLinePerspectiveZ(pi[1], pi[2], y, renderBuffer, mipmap);
+                        }
+                    }
+                    return;
+                }
+            }
+        }
+
+        paintAffine(yTop, yBottom, mipmap, renderBuffer,
+                projectedPoint1, projectedPoint2, projectedPoint3,
+                texturePoint1, texturePoint2, texturePoint3,
+                z1, z2, z3);
+    }
+
+    /**
+     * Computes the perspective-correct texture coordinate at a screen-space
+     * point known to lie inside the triangle.
+     *
+     * <p>Screen-space barycentric weights are divided by the camera-space z
+     * of each vertex and renormalized — the same (u/z, v/z, 1/z) math the
+     * perspective-correct scanline path uses — so the returned coordinate
+     * matches the texel that was actually painted at that pixel, even at
+     * steep viewing angles. Texture coordinates are in primary-texture
+     * pixels (no mipmap factor applied).</p>
+     *
+     * @return double[2] with {u, v} in primary-texture pixels
+     */
+    private static double[] textureCoordinateAt(final Point2D point,
+                                                final Point2D p1, final Point2D p2, final Point2D p3,
+                                                final Vertex v1, final Vertex v2, final Vertex v3,
+                                                final RenderingContext renderBuffer) {
+        final double denom = (p2.y - p3.y) * (p1.x - p3.x)
+                + (p3.x - p2.x) * (p1.y - p3.y);
+        if (Math.abs(denom) < 1e-9)
+            // degenerate on screen; the hit pixel is effectively a vertex
+            return new double[]{v1.textureCoordinate.x, v1.textureCoordinate.y};
+
+        double w1 = ((p2.y - p3.y) * (point.x - p3.x)
+                + (p3.x - p2.x) * (point.y - p3.y)) / denom;
+        double w2 = ((p3.y - p1.y) * (point.x - p3.x)
+                + (p1.x - p3.x) * (point.y - p3.y)) / denom;
+        double w3 = 1d - w1 - w2;
+
+        final double z1 = v1.transformedCoordinate(renderBuffer).z;
+        final double z2 = v2.transformedCoordinate(renderBuffer).z;
+        final double z3 = v3.transformedCoordinate(renderBuffer).z;
+
+        if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z
+                && z3 > PERSPECTIVE_MIN_Z) {
+            w1 /= z1;
+            w2 /= z2;
+            w3 /= z3;
+            final double sum = w1 + w2 + w3;
+            w1 /= sum;
+            w2 /= sum;
+            w3 /= sum;
+        }
+        // near-plane straddlers: plain screen-space barycentric (affine),
+        // matching the affine fallback path used for painting them
+
+        return new double[]{
+                w1 * v1.textureCoordinate.x + w2 * v2.textureCoordinate.x
+                        + w3 * v3.textureCoordinate.x,
+                w1 * v1.textureCoordinate.y + w2 * v2.textureCoordinate.y
+                        + w3 * v3.textureCoordinate.y};
+    }
+
+    /**
+     * SDF (signed distance field) rendering path. Coverage is not stored
+     * in the texture; it is re-derived per pixel from a smooth distance
+     * mask, so edges stay sharp at any magnification and fade to clean
+     * gray under minification. Layers: {@code texture.primaryBitmap} is
+     * the background color layer, {@code texture.sdfForeground} the ink
+     * color layer (both sampled nearest — they are flat per region),
+     * {@code texture.sdfMask} the distance field (sampled bilinear).
+     *
+     * <p>Minification is handled analytically: the coverage window is
+     * widened by the screen-space pixel footprint, which gives correct
+     * area coverage without a mipmap chain.</p>
+     *
+     * @param scaleFactor the same screen-pixels-per-texel estimate the
+     *                    mipmap selection uses (times 1.2)
+     */
+    private void paintSdf(final int yTop, final int yBottom,
+                          final RenderingContext renderBuffer,
+                          final Point2D projectedPoint1, final Point2D projectedPoint2,
+                          final Point2D projectedPoint3, final double scaleFactor,
+                          final Vertex v1, final Vertex v2, final Vertex v3) {
+        // SDF (text/decal) is alpha-class — it paints in the
+        // back-to-front alpha pass only, without depth interaction.
+        if (renderBuffer.depthPass == 1)
+            return;
+        // Per-axis screen-space UV gradients (affine estimate — adequate
+        // for a footprint). Text on an angled plane is minified mostly
+        // along ONE axis; an isotropic average would blur the axis that
+        // still has resolution to spare.
+        double footX;
+        double footY;
+        final double ex = projectedPoint2.x - projectedPoint1.x;
+        final double ey = projectedPoint2.y - projectedPoint1.y;
+        final double fx3 = projectedPoint3.x - projectedPoint1.x;
+        final double fy3 = projectedPoint3.y - projectedPoint1.y;
+        final double denom = ex * fy3 - fx3 * ey;
+        if (Math.abs(denom) > 1e-9) {
+            final double u1 = v1.textureCoordinate.x;
+            final double vv1 = v1.textureCoordinate.y;
+            final double du21 = v2.textureCoordinate.x - u1;
+            final double dv21 = v2.textureCoordinate.y - vv1;
+            final double du31 = v3.textureCoordinate.x - u1;
+            final double dv31 = v3.textureCoordinate.y - vv1;
+            final double dudx = (du21 * fy3 - du31 * ey) / denom;
+            final double dudy = (du31 * ex - du21 * fx3) / denom;
+            final double dvdx = (dv21 * fy3 - dv31 * ey) / denom;
+            final double dvdy = (dv31 * ex - dv21 * fx3) / denom;
+            footX = Math.hypot(dudx, dvdx);
+            footY = Math.hypot(dudy, dvdy);
+        } else {
+            footX = footY = 1.2d / scaleFactor;
+        }
+
+        // The coverage window follows the SHARPEST axis: one screen pixel
+        // spans texelsPerPixel texels along it, i.e.
+        // texelsPerPixel/(2*spread) of the normalized mask range; aaK
+        // converts a mask sample (0..255, edge at 127.5) into fixed-point
+        // coverage in [0, 256]: cov = (127.5 - d)*aaK + 128.
+        final double maxFootprint = Math.max(footX, footY);
+        double texelsPerPixel = Math.max(Math.min(footX, footY), 0.01d);
+        if (maxFootprint > 1d) {
+            texelsPerPixel /= SDF_SHARPEN;
+        }
+        final double aaK = (2d * texture.sdfSpreadTexels) / texelsPerPixel / 255d * 256d;
+
+        // No mip chain for SDF layers. A distance field's edge gradient
+        // spans just 2 texels, so a half/quarter-res mask visibly melts
+        // glyph edges — and because the two triangles of a rectangle get
+        // slightly different perspective footprints, they crossed mip
+        // thresholds at different distances, producing a hard diagonal
+        // quality split plus sudden blur steps while dollying (observed
+        // 2026-09-06). Sampling the primary field costs some bandwidth
+        // under minification, but text surfaces are small and the
+        // per-pixel sample count is what matters. Quality then degrades
+        // smoothly with distance instead of in steps.
+        final TextureBitmap mask = texture.sdfMask;
+        final TextureBitmap fg = texture.sdfForeground;
+        final TextureBitmap bg = texture.primaryBitmap;
+        final double mf = mask.multiplicationFactor;
+
+        // Under minification, area-correct coverage reads as low-contrast
+        // gray haze. Two perceptual corrections (A/B-tuned 2026-09-06 on
+        // far+angled text): SDF_SHARPEN narrows the coverage window below
+        // one pixel (kills the haze halo, keeps edges crisp at the cost
+        // of a little shimmer), and a mild coverage gamma < 1 (stem
+        // darkening, the small-ppm font rasterizer trick) keeps thin
+        // strokes present.
+        // Knobs: -De3d.sdf.gamma=1.4 forces a fixed gamma (0 = auto),
+        // -De3d.sdf.sharpen=1 restores the pixel-exact window.
+        final int[] covLut;
+        final double gamma = SDF_GAMMA != 0 ? SDF_GAMMA
+                : Math.max(0.75d, 1d - 0.08d * (Math.log(maxFootprint) / Math.log(2d)));
+        if (gamma != 1d && maxFootprint > 1d) {
+            covLut = new int[257];
+            for (int i = 0; i <= 256; i++) {
+                covLut[i] = Math.min(256, (int) (256d * Math.pow(i / 256d, gamma)));
+            }
+        } else {
+            covLut = null;
+        }
+
+        boolean usePerspective = false;
+        double su1 = 0, sv1 = 0, sw1 = 0;
+        double su2 = 0, sv2 = 0, sw2 = 0;
+        double su3 = 0, sv3 = 0, sw3 = 0;
+        if (perspectiveCorrectionEnabled) {
+            final double z1 = v1.transformedCoordinate(renderBuffer).z;
+            final double z2 = v2.transformedCoordinate(renderBuffer).z;
+            final double z3 = v3.transformedCoordinate(renderBuffer).z;
+            if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z && z3 > PERSPECTIVE_MIN_Z) {
+                // Same affine-sufficiency test as the coverage path
+                // (mask/fg/bg are all primary resolution, mf = 1).
+                final double tu1 = v1.textureCoordinate.x;
+                final double tv1 = v1.textureCoordinate.y;
+                final double tu2 = v2.textureCoordinate.x;
+                final double tv2 = v2.textureCoordinate.y;
+                final double tu3 = v3.textureCoordinate.x;
+                final double tv3 = v3.textureCoordinate.y;
+                final double texelSpan = Math.max(
+                        Math.max(Math.abs(tu2 - tu1), Math.abs(tv2 - tv1)),
+                        Math.max(
+                                Math.max(Math.abs(tu3 - tu1), Math.abs(tv3 - tv1)),
+                                Math.max(Math.abs(tu3 - tu2), Math.abs(tv3 - tv2))));
+                final double zMin = Math.min(z1, Math.min(z2, z3));
+                final double zMax = Math.max(z1, Math.max(z2, z3));
+                usePerspective = texelSpan * (zMax / zMin - 1d) >= 2d;
+                if (usePerspective) {
+                    sw1 = 1d / z1;
+                    sw2 = 1d / z2;
+                    sw3 = 1d / z3;
+                    su1 = tu1 * mf * sw1;
+                    sv1 = tv1 * mf * sw1;
+                    su2 = tu2 * mf * sw2;
+                    sv2 = tv2 * mf * sw2;
+                    su3 = tu3 * mf * sw3;
+                    sv3 = tv3 * mf * sw3;
+                }
+            }
+        }
+
+        if (SDF_DEBUG && (usePerspective != sdfDebugLastPerspective
+                || Math.abs(footY - sdfDebugLastFootY) > 0.5)) {
+            sdfDebugLastPerspective = usePerspective;
+            sdfDebugLastFootY = footY;
+            System.err.printf("[SDF] perspective=%b footX=%.2f footY=%.2f aaK=%.3f%n",
+                    usePerspective, footX, footY, aaK);
+        }
+
+        if (usePerspective) {
+            final PerspectiveBorderInterpolator[] pi = PERSPECTIVE_INTERPOLATORS.get();
+            pi[0].setPoints(projectedPoint1, projectedPoint2, su1, sv1, sw1, su2, sv2, sw2);
+            pi[1].setPoints(projectedPoint1, projectedPoint3, su1, sv1, sw1, su3, sv3, sw3);
+            pi[2].setPoints(projectedPoint2, projectedPoint3, su2, sv2, sw2, su3, sv3, sw3);
+
+            for (int y = yTop; y <= yBottom; y++) {
+                if (pi[0].containsY(y)) {
+                    if (pi[1].containsY(y))
+                        drawHorizontalLinePerspectiveSdf(pi[0], pi[1], y, renderBuffer, mask, fg, bg, aaK, covLut);
+                    else if (pi[2].containsY(y))
+                        drawHorizontalLinePerspectiveSdf(pi[0], pi[2], y, renderBuffer, mask, fg, bg, aaK, covLut);
+                } else if (pi[1].containsY(y)) {
+                    if (pi[2].containsY(y))
+                        drawHorizontalLinePerspectiveSdf(pi[1], pi[2], y, renderBuffer, mask, fg, bg, aaK, covLut);
+                }
+            }
+            return;
+        }
+
+        final PolygonBorderInterpolator[] interpolators = INTERPOLATORS.get();
+        final PolygonBorderInterpolator pbi1 = interpolators[0];
+        final PolygonBorderInterpolator pbi2 = interpolators[1];
+        final PolygonBorderInterpolator pbi3 = interpolators[2];
+
+        pbi1.setPoints(projectedPoint1, projectedPoint2, v1.textureCoordinate, v2.textureCoordinate);
+        pbi2.setPoints(projectedPoint1, projectedPoint3, v1.textureCoordinate, v3.textureCoordinate);
+        pbi3.setPoints(projectedPoint2, projectedPoint3, v2.textureCoordinate, v3.textureCoordinate);
+
+        for (int y = yTop; y <= yBottom; y++) {
+            if (pbi1.containsY(y)) {
+                if (pbi2.containsY(y))
+                    drawHorizontalLineSdf(pbi1, pbi2, y, renderBuffer, mask, fg, bg, aaK, mf, covLut);
+                else if (pbi3.containsY(y))
+                    drawHorizontalLineSdf(pbi1, pbi3, y, renderBuffer, mask, fg, bg, aaK, mf, covLut);
+            } else if (pbi2.containsY(y)) {
+                if (pbi3.containsY(y))
+                    drawHorizontalLineSdf(pbi2, pbi3, y, renderBuffer, mask, fg, bg, aaK, mf, covLut);
+            }
+        }
+    }
+
+    /**
+     * SDF scanline, affine mapping. Texture coordinates are scaled by the
+     * selected mip's multiplication factor (all layers share one mip
+     * level, so one factor covers mask and both color layers).
+     */
+    private void drawHorizontalLineSdf(final PolygonBorderInterpolator line1,
+                                       final PolygonBorderInterpolator line2, final int y,
+                                       final RenderingContext renderBuffer,
+                                       final TextureBitmap mask, final TextureBitmap fg,
+                                       final TextureBitmap bg, final double aaK,
+                                       final double mf, final int[] covLut) {
+        line1.setCurrentY(y);
+        line2.setCurrentY(y);
+
+        int x1 = line1.getX();
+        int x2 = line2.getX();
+
+        final double tx1, ty1, tx2, ty2;
+        if (x1 <= x2) {
+            tx1 = line1.getTX() * mf;
+            ty1 = line1.getTY() * mf;
+            tx2 = line2.getTX() * mf;
+            ty2 = line2.getTY() * mf;
+        } else {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+            tx1 = line2.getTX() * mf;
+            ty1 = line2.getTY() * mf;
+            tx2 = line1.getTX() * mf;
+            ty2 = line1.getTY() * mf;
+        }
+
+        final double realWidth = x2 - x1;
+        final double realX1 = x1;
+
+        if (x1 < renderBuffer.renderMinX)
+            x1 = renderBuffer.renderMinX;
+        if (x2 >= renderBuffer.renderMaxX)
+            x2 = renderBuffer.renderMaxX;
+
+        int renderBufferOffset = (y * renderBuffer.width) + x1;
+        final int[] renderBufferPixels = renderBuffer.pixels;
+
+        final double txStep = (tx2 - tx1) / realWidth;
+        final double tyStep = (ty2 - ty1) / realWidth;
+
+        double tx = tx1 + txStep * (x1 - realX1);
+        double ty = ty1 + tyStep * (x1 - realX1);
+
+        final int[] maskPixels = mask.pixels;
+        final int[] fgPixels = fg.pixels;
+        final int[] bgPixels = bg.pixels;
+        final int mw = mask.width;
+        final int mh = mask.height;
+        final double bilinearCapX = mw - 1.0001d;
+        final double bilinearCapY = mh - 1.0001d;
+        final int mw1 = mw - 1;
+        final int mh1 = mh - 1;
+
+        for (int x = x1; x < x2; x++) {
+            // Fixed-point bilinear distance fetch (8.8 fractions)
+            final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
+            final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
+            final int x0 = (int) ctx;
+            final int y0 = (int) cty;
+            final int fx = (int) ((ctx - x0) * 256);
+            final int fy = (int) ((cty - y0) * 256);
+            final int row0 = y0 * mw + x0;
+            final int row1 = row0 + mw;
+            final int m00 = (maskPixels[row0] >> 16) & 0xff;
+            final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
+            final int m01 = (maskPixels[row1] >> 16) & 0xff;
+            final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
+            final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
+                    + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
+
+            int cov = (int) ((127.5d - d) * aaK + 128d);
+            if (cov < 0) cov = 0;
+            else if (cov > 256) cov = 256;
+            if (covLut != null) cov = covLut[cov];
+
+            int itx = (int) tx;
+            int ity = (int) ty;
+            if (itx < 0) itx = 0;
+            else if (itx > mw1) itx = mw1;
+            if (ity < 0) ity = 0;
+            else if (ity > mh1) ity = mh1;
+            final int addr = ity * mw + itx;
+
+            final int srcPixel;
+            if (cov <= 0) {
+                srcPixel = bgPixels[addr];
+            } else if (cov >= 256) {
+                srcPixel = fgPixels[addr];
+            } else {
+                final int bgP = bgPixels[addr];
+                final int fgP = fgPixels[addr];
+                final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
+                final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
+                final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
+                final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
+                srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
+            }
+
+            final int srcAlpha = (srcPixel >> 24) & 0xff;
+            if (srcAlpha == 255) {
+                renderBufferPixels[renderBufferOffset] = srcPixel;
+            } else if (srcAlpha != 0) {
+                final int destPixel = renderBufferPixels[renderBufferOffset];
+                final int destR = (destPixel >> 16) & 0xff;
+                final int destG = (destPixel >> 8) & 0xff;
+                final int destB = destPixel & 0xff;
+                final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+                final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+                final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+                renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+            }
+
+            tx += txStep;
+            ty += tyStep;
+            renderBufferOffset++;
+        }
+    }
+
+    /**
+     * SDF scanline with Quake-style subdivided perspective correction —
+     * same stepping structure as {@link #drawHorizontalLinePerspective},
+     * with the coverage fetch replaced by the distance-field evaluation.
+     */
+    private void drawHorizontalLinePerspectiveSdf(
+            final PerspectiveBorderInterpolator line1,
+            final PerspectiveBorderInterpolator line2,
+            final int y,
+            final RenderingContext renderBuffer,
+            final TextureBitmap mask, final TextureBitmap fg,
+            final TextureBitmap bg, final double aaK, final int[] covLut) {
+
+        line1.setCurrentY(y);
+        line2.setCurrentY(y);
+
+        int x1 = line1.getX();
+        int x2 = line2.getX();
+
+        final double su1, sv1, sw1;
+        final double su2, sv2, sw2;
+
+        if (x1 <= x2) {
+            su1 = line1.getSU();
+            sv1 = line1.getSV();
+            sw1 = line1.getSW();
+            su2 = line2.getSU();
+            sv2 = line2.getSV();
+            sw2 = line2.getSW();
+        } else {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+            su1 = line2.getSU();
+            sv1 = line2.getSV();
+            sw1 = line2.getSW();
+            su2 = line1.getSU();
+            sv2 = line1.getSV();
+            sw2 = line1.getSW();
+        }
+
+        final double realWidth = x2 - x1;
+        final double realX1 = x1;
+
+        if (x1 < renderBuffer.renderMinX)
+            x1 = renderBuffer.renderMinX;
+        if (x2 >= renderBuffer.renderMaxX)
+            x2 = renderBuffer.renderMaxX;
+
+        final int span = x2 - x1;
+        if (span <= 0)
+            return;
+
+        int renderBufferOffset = (y * renderBuffer.width) + x1;
+
+        final double dsu = (su2 - su1) / realWidth;
+        final double dsv = (sv2 - sv1) / realWidth;
+        final double dsw = (sw2 - sw1) / realWidth;
+
+        double su = su1 + dsu * (x1 - realX1);
+        double sv = sv1 + dsv * (x1 - realX1);
+        double sw = sw1 + dsw * (x1 - realX1);
+
+        final int[] renderBufferPixels = renderBuffer.pixels;
+
+        final int[] maskPixels = mask.pixels;
+        final int[] fgPixels = fg.pixels;
+        final int[] bgPixels = bg.pixels;
+        final int mw = mask.width;
+        final int mh = mask.height;
+        final double bilinearCapX = mw - 1.0001d;
+        final double bilinearCapY = mh - 1.0001d;
+        final int mw1 = mw - 1;
+        final int mh1 = mh - 1;
+
+        // Same adaptive-interval ladder as the coverage path.
+        final double ue1 = su1 / sw1;
+        final double ue2 = su2 / sw2;
+        final double ve1 = sv1 / sw1;
+        final double ve2 = sv2 / sw2;
+        final double wRatio = Math.max(sw1, sw2) / Math.min(sw1, sw2);
+        final double texelRate = Math.max(Math.abs(ue2 - ue1), Math.abs(ve2 - ve1))
+                / realWidth * wRatio;
+        final double k = Math.abs(dsw) / Math.min(sw1, sw2);
+        final double curvature = texelRate * k;
+        final int interval = curvature < 0.5 / (16 * 16) ? PERSPECTIVE_CORRECTION_INTERVAL
+                : curvature < 0.5 / (8 * 8) ? 8
+                : curvature < 0.5 / (4 * 4) ? 4
+                : curvature < 0.5 / (2 * 2) ? 2 : 1;
+        final double invInterval = 1d / interval;
+
+        int done = 0;
+        double invW = 1d / sw;
+        double tx = su * invW;
+        double ty = sv * invW;
+        while (done < span) {
+            final int block = Math.min(interval, span - done);
+
+            su += dsu * block;
+            sv += dsv * block;
+            sw += dsw * block;
+            final double invWNext = 1d / sw;
+            final double txNext = su * invWNext;
+            final double tyNext = sv * invWNext;
+
+            final double invBlock = block == interval ? invInterval : 1d / block;
+            final double txStep = (txNext - tx) * invBlock;
+            final double tyStep = (tyNext - ty) * invBlock;
+
+            for (int i = 0; i < block; i++) {
+                // Fixed-point bilinear distance fetch (8.8 fractions)
+                final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
+                final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
+                final int x0 = (int) ctx;
+                final int y0 = (int) cty;
+                final int fx = (int) ((ctx - x0) * 256);
+                final int fy = (int) ((cty - y0) * 256);
+                final int row0 = y0 * mw + x0;
+                final int row1 = row0 + mw;
+                final int m00 = (maskPixels[row0] >> 16) & 0xff;
+                final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
+                final int m01 = (maskPixels[row1] >> 16) & 0xff;
+                final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
+                final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
+                        + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
+
+                int cov = (int) ((127.5d - d) * aaK + 128d);
+                if (cov < 0) cov = 0;
+                else if (cov > 256) cov = 256;
+                if (covLut != null) cov = covLut[cov];
+
+                int itx = (int) tx;
+                int ity = (int) ty;
+                if (itx < 0) itx = 0;
+                else if (itx > mw1) itx = mw1;
+                if (ity < 0) ity = 0;
+                else if (ity > mh1) ity = mh1;
+                final int addr = ity * mw + itx;
+
+                final int srcPixel;
+                if (cov <= 0) {
+                    srcPixel = bgPixels[addr];
+                } else if (cov >= 256) {
+                    srcPixel = fgPixels[addr];
+                } else {
+                    final int bgP = bgPixels[addr];
+                    final int fgP = fgPixels[addr];
+                    final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
+                    final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
+                    final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
+                    final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
+                    srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
+                }
+
+                final int srcAlpha = (srcPixel >> 24) & 0xff;
+                if (srcAlpha == 255) {
+                    renderBufferPixels[renderBufferOffset] = srcPixel;
+                } else if (srcAlpha != 0) {
+                    final int destPixel = renderBufferPixels[renderBufferOffset];
+                    final int destR = (destPixel >> 16) & 0xff;
+                    final int destG = (destPixel >> 8) & 0xff;
+                    final int destB = destPixel & 0xff;
+                    final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+                    final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+                    final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+                    renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+                }
+
+                tx += txStep;
+                ty += tyStep;
+                renderBufferOffset++;
+            }
+
+            done += block;
+        }
+    }
+
+    /**
+     * Affine texture mapping (u, v linear in screen space). Used for
+     * near-plane straddlers and for triangles small/flat enough that
+     * affine is within half a texel of exact perspective mapping.
+     */
+    private void paintAffine(final int yTop, final int yBottom,
+                             final TextureBitmap mipmap,
+                             final RenderingContext renderBuffer,
+                             final Point2D projectedPoint1, final Point2D projectedPoint2,
+                             final Point2D projectedPoint3,
+                             final Point2D texturePoint1, final Point2D texturePoint2,
+                             final Point2D texturePoint3,
+                             final double z1, final double z2, final double z3) {
+        final PolygonBorderInterpolator[] interpolators = INTERPOLATORS.get();
+        final PolygonBorderInterpolator pbi1 = interpolators[0];
+        final PolygonBorderInterpolator pbi2 = interpolators[1];
+        final PolygonBorderInterpolator pbi3 = interpolators[2];
+
+        pbi1.setPoints(projectedPoint1, projectedPoint2, texturePoint1, texturePoint2);
+        pbi2.setPoints(projectedPoint1, projectedPoint3, texturePoint1, texturePoint3);
+        pbi3.setPoints(projectedPoint2, projectedPoint3, texturePoint2, texturePoint3);
+
+        final double zw1 = 1d / z1;
+        final double zw2 = 1d / z2;
+        final double zw3 = 1d / z3;
+        pbi1.setPointsZW(zw1, zw2);
+        pbi2.setPointsZW(zw1, zw3);
+        pbi3.setPointsZW(zw2, zw3);
+        for (int y = yTop; y <= yBottom; y++) {
+            if (pbi1.containsY(y)) {
+                if (pbi2.containsY(y))
+                    drawHorizontalLineZ(pbi1, pbi2, y, renderBuffer, mipmap);
+                else if (pbi3.containsY(y))
+                    drawHorizontalLineZ(pbi1, pbi3, y, renderBuffer, mipmap);
+            } else if (pbi2.containsY(y)) {
+                if (pbi3.containsY(y))
+                    drawHorizontalLineZ(pbi2, pbi3, y, renderBuffer, mipmap);
+            }
+        }
+
+    }
+
+    /**
+     * Z-buffer variant of {@link #drawHorizontalLine}: per-pixel depth
+     * test (biased 1/z, linear along the span) BEFORE the texture fetch.
+     * Opaque texels write depth; blended texels write color only.
+     */
+    private void drawHorizontalLineZ(final PolygonBorderInterpolator line1,
+                                     final PolygonBorderInterpolator line2,
+                                     final int y,
+                                     final RenderingContext renderBuffer,
+                                     final TextureBitmap textureBitmap) {
+
+        line1.setCurrentY(y);
+        line2.setCurrentY(y);
+
+        int x1 = line1.getX();
+        int x2 = line2.getX();
+
+        final double tx1, ty1, zw1;
+        final double tx2, ty2, zw2;
+
+        if (x1 <= x2) {
+            tx1 = line1.getTX() * textureBitmap.multiplicationFactor;
+            ty1 = line1.getTY() * textureBitmap.multiplicationFactor;
+            zw1 = line1.getZW();
+            tx2 = line2.getTX() * textureBitmap.multiplicationFactor;
+            ty2 = line2.getTY() * textureBitmap.multiplicationFactor;
+            zw2 = line2.getZW();
+        } else {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+
+            tx1 = line2.getTX() * textureBitmap.multiplicationFactor;
+            ty1 = line2.getTY() * textureBitmap.multiplicationFactor;
+            zw1 = line2.getZW();
+
+            tx2 = line1.getTX() * textureBitmap.multiplicationFactor;
+            ty2 = line1.getTY() * textureBitmap.multiplicationFactor;
+            zw2 = line1.getZW();
+        }
+
+        final double realWidth = x2 - x1;
+        final double realX1 = x1;
+
+        if (x1 < renderBuffer.renderMinX)
+            x1 = renderBuffer.renderMinX;
+
+        // x2 is exclusive: clamp to renderMaxX (see drawHorizontalLine)
+        if (x2 >= renderBuffer.renderMaxX)
+            x2 = renderBuffer.renderMaxX;
+
+        if (PROF) {
+            PROF_SPANS.incrementAndGet();
+            PROF_PIXELS.addAndGet(Math.max(0, x2 - x1));
+        }
+
+        int renderBufferOffset = (y * renderBuffer.width) + x1;
+        final int[] renderBufferPixels = renderBuffer.pixels;
+        final float[] depth = renderBuffer.depth;
+        // Alpha pass (depthPass 2): depth-test but never depth-write
+        final boolean writeDepth = renderBuffer.depthPass != 2;
+
+        final double txStep = (tx2 - tx1) / realWidth;
+        final double tyStep = (ty2 - ty1) / realWidth;
+        final double dzw = (zw2 - zw1) / realWidth;
+        double tx = tx1 + txStep * (x1 - realX1);
+        double ty = ty1 + tyStep * (x1 - realX1);
+        double zw = zw1 + dzw * (x1 - realX1);
+
+        final int[] texPixels = textureBitmap.pixels;
+        final int texW = textureBitmap.width;
+        final int texH = textureBitmap.height;
+        final int texWMinus1 = texW - 1;
+        final int texHMinus1 = texH - 1;
+        // texture is null in unit tests: clamp (see drawHorizontalLine)
+        final boolean wrap = texture != null && texture.wrap;
+
+        for (int x = x1; x < x2; x++) {
+
+            if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+                int itx = (int) tx;
+                int ity = (int) ty;
+
+                if (wrap) {
+                    itx = Math.floorMod(itx, texW);
+                    ity = Math.floorMod(ity, texH);
+                } else {
+                    if (itx < 0) itx = 0;
+                    else if (itx > texWMinus1) itx = texWMinus1;
+
+                    if (ity < 0) ity = 0;
+                    else if (ity > texHMinus1) ity = texHMinus1;
+                }
+
+                final int srcPixel = texPixels[ity * texW + itx];
+                final int srcAlpha = (srcPixel >> 24) & 0xff;
+
+                if (srcAlpha == 255) {
+                    renderBufferPixels[renderBufferOffset] = srcPixel;
+                    if (writeDepth)
+                        depth[renderBufferOffset] = (float) zw;
+                } else if (srcAlpha != 0) {
+                    // Translucent: blend without writing depth
+                    final int destPixel = renderBufferPixels[renderBufferOffset];
+                    final int destR = (destPixel >> 16) & 0xff;
+                    final int destG = (destPixel >> 8) & 0xff;
+                    final int destB = destPixel & 0xff;
+
+                    final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+                    final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+                    final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+
+                    renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+                }
+            }
+
+            tx += txStep;
+            ty += tyStep;
+            zw += dzw;
+            renderBufferOffset++;
+        }
+
+    }
+
+    /**
+     * Checks if backface culling is enabled for this triangle.
+     *
+     * @return {@code true} if backface culling is enabled
+     */
+    public boolean isBackfaceCullingEnabled() {
+        return backfaceCulling;
+    }
+
+    /**
+     * Enables or disables backface culling for this triangle.
+     *
+     * @param backfaceCulling {@code true} to enable backface culling
+     */
+    public void setBackfaceCulling(final boolean backfaceCulling) {
+        this.backfaceCulling = backfaceCulling;
+    }
+
+    /**
+     * Draws the triangle border edges in yellow (for debugging).
+     *
+     * @param renderBuffer the rendering context
+     */
+    private void showBorders(final RenderingContext renderBuffer) {
+
+        final Point2D projectedPoint1 = vertices.get(0).onScreenCoordinate(renderBuffer);
+        final Point2D projectedPoint2 = vertices.get(1).onScreenCoordinate(renderBuffer);
+        final Point2D projectedPoint3 = vertices.get(2).onScreenCoordinate(renderBuffer);
+
+        final int x1 = (int) projectedPoint1.x;
+        final int y1 = (int) projectedPoint1.y;
+        final int x2 = (int) projectedPoint2.x;
+        final int y2 = (int) projectedPoint2.y;
+        final int x3 = (int) projectedPoint3.x;
+        final int y3 = (int) projectedPoint3.y;
+
+        renderBuffer.executeWithGraphics(g -> {
+            g.setColor(Color.YELLOW);
+            g.drawLine(x1, y1, x2, y2);
+            g.drawLine(x3, y3, x2, y2);
+            g.drawLine(x1, y1, x3, y3);
+        });
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java
new file mode 100644 (file)
index 0000000..6084ee3
--- /dev/null
@@ -0,0 +1,471 @@
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.HiZPyramid;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.StereoEye;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+
+/**
+ * A block of textured triangles stored as flat primitive arrays
+ * (struct-of-arrays) instead of one object graph per triangle. Built once
+ * (off the render thread), then every frame a single tight loop applies
+ * the composed camera transform to all vertices — sequential memory
+ * access instead of pointer chasing through {@code Vertex}/{@code Point3D}
+ * soup, which is what made the transform phase memory-latency-bound.
+ *
+ * <p>Interaction with the rest of the pipeline is unchanged: each visible
+ * triangle is queued as a thin preallocated {@link MeshTriangle} handle
+ * into the same {@link RenderAggregator}, sorted by the same comparator,
+ * binned into the same tile grid, and painted by the same
+ * {@link TexturedTriangle#paintFlat} core — bit-exact with the
+ * object-backed path.</p>
+ *
+ * <p>Subpixel-cull verdicts are cached per triangle ({@link #cullEpoch}):
+ * while the verdict epoch holds, a culled triangle costs one integer
+ * compare per frame — no vertex math at all.</p>
+ *
+ * <p>Limitations vs object-backed triangles: no mouse picking, no SDF
+ * textures (rejected at build), no GI/lightmap integration.</p>
+ */
+public final class TriangleMeshBlock extends AbstractShape {
+
+    // Build-time geometry: 9 world doubles and 6 UV doubles per triangle.
+    private final double[] world;
+    private final double[] uv;
+    private final Texture[] textures;
+    private final boolean backfaceCull;
+    private final int triCount;
+    private final MeshTriangle[] handles;
+    private final Box boundingBox;
+
+    // Per-slot projected state: 3 screen/camera doubles per triangle per
+    // slot. (Sort Z and binning bounds are published into the handles'
+    // per-slot fields at transform time, not kept here.)
+    private final double[][] projX = new double[3][];
+    private final double[][] projY = new double[3][];
+    private final double[][] camZ = new double[3][];
+
+    // Subpixel-cull verdicts: epoch in which the triangle was found tiny.
+    private final int[] cullEpoch;
+
+    // Near-plane clip output per slot: packed (offset << 3) | count into
+    // clipStore, -1 = not clipped. Clip data is 7 doubles per loop vertex
+    // (camera x, y, z, u, v + screen x, y), at most 4 vertices per
+    // straddler. Sort Z and binning bounds for straddlers are derived
+    // from the store, exactly like the object path derives them from the
+    // clipped loop. clipTtd keeps the ORIGINAL triangle's UV perimeter
+    // per straddler (index = clip offset / 28): mipmap selection must
+    // use the unclipped texel density, exactly like the object path.
+    private static final int CLIP_STRIDE = 7;
+    private static final int CLIP_ENTRY = 4 * CLIP_STRIDE;
+    private final int[][] clipRef = new int[3][];
+    private final double[][] clipStore = new double[3][];
+    private final double[][] clipTtd = new double[3][];
+    private final int[] clipUsed = new int[3];
+
+    // Scratch for the composed top transform of the current transform call.
+    private final double[] top = new double[12];
+
+    /**
+     * Builds a block from baked world-space triangle soup.
+     *
+     * @param world         9 doubles per triangle (x0,y0,z0,x1,...), world
+     *                      space; the array is adopted, not copied
+     * @param uv            6 doubles per triangle (u0,v0,...) in primary
+     *                      texture pixels; adopted
+     * @param textures      one texture per triangle
+     * @param backfaceCull  cull clockwise triangles on screen
+     */
+    public TriangleMeshBlock(final double[] world, final double[] uv,
+                             final Texture[] textures,
+                             final boolean backfaceCull) {
+        this.triCount = textures.length;
+        if (world.length != triCount * 9 || uv.length != triCount * 6)
+            throw new IllegalArgumentException("array length mismatch");
+        this.world = world;
+        this.uv = uv;
+        this.textures = textures;
+        this.backfaceCull = backfaceCull;
+        for (final Texture texture : textures)
+            if (texture != null && texture.isSdf())
+                throw new IllegalArgumentException(
+                        "SDF textures are not supported in mesh blocks");
+
+        this.handles = new MeshTriangle[triCount];
+        for (int t = 0; t < triCount; t++)
+            handles[t] = new MeshTriangle(this, t, textures[t]);
+
+        this.cullEpoch = new int[triCount];
+        java.util.Arrays.fill(cullEpoch, -1);
+
+        for (int s = 0; s < 3; s++) {
+            projX[s] = new double[triCount * 3];
+            projY[s] = new double[triCount * 3];
+            camZ[s] = new double[triCount * 3];
+            clipRef[s] = new int[triCount];
+            java.util.Arrays.fill(clipRef[s], -1);
+            clipStore[s] = new double[256];
+            clipTtd[s] = new double[16];
+        }
+
+        double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE,
+                minZ = Double.MAX_VALUE;
+        double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE,
+                maxZ = -Double.MAX_VALUE;
+        for (int i = 0; i < world.length; i += 3) {
+            if (world[i] < minX) minX = world[i];
+            if (world[i] > maxX) maxX = world[i];
+            if (world[i + 1] < minY) minY = world[i + 1];
+            if (world[i + 1] > maxY) maxY = world[i + 1];
+            if (world[i + 2] < minZ) minZ = world[i + 2];
+            if (world[i + 2] > maxZ) maxZ = world[i + 2];
+        }
+        boundingBox = new Box(new Point3D(minX, minY, minZ),
+                new Point3D(maxX, maxY, maxZ));
+    }
+
+    public int triCount() {
+        return triCount;
+    }
+
+    @Override
+    public Box getBoundingBox() {
+        return boundingBox;
+    }
+
+    @Override
+    public int getTransformWeight(final RenderingContext renderingContext) {
+        return Math.max(1, triCount);
+    }
+
+    /**
+     * Transforms every triangle of the block with the composed top
+     * transform of the stack (hoisted out of the loop), culls (near
+     * plane, subpixel with verdict cache, viewport) and queues a thin
+     * handle per surviving triangle. All expressions replicate
+     * {@code TransformStack.transform} /
+     * {@code Vertex.calculateLocationRelativeToViewer} exactly, so output
+     * is bit-identical with the object-backed path.
+     */
+    @Override
+    public void transform(final TransformStack transforms,
+                          final RenderAggregator aggregator,
+                          final RenderingContext renderingContext) {
+        final int slot = renderingContext.vertexSlot;
+        final double[] px = projX[slot];
+        final double[] py = projY[slot];
+        final double[] cz = camZ[slot];
+        final int[] cref = clipRef[slot];
+        clipUsed[slot] = 0;
+
+        transforms.getTopTransform(top);
+        final double r0 = top[0], r1 = top[1], r2 = top[2];
+        final double r3 = top[3], r4 = top[4], r5 = top[5];
+        final double r6 = top[6], r7 = top[7], r8 = top[8];
+        final double t0 = top[9], t1 = top[10], t2 = top[11];
+
+        final double near = renderingContext.nearPlaneDistance;
+        final double scale = renderingContext.projectionScale;
+        final double centerX = renderingContext.centerCoordinate.x;
+        final double centerY = renderingContext.centerCoordinate.y;
+        final double stereo = renderingContext.stereoViewportOffsetX;
+
+        // Hi-Z whole-block occlusion: test the world AABB against last
+        // frame's depth pyramid before touching a single triangle.
+        // Skipped in stereo (the pyramid is mono-view) and whenever a
+        // corner crosses the near plane (its projection is unreliable).
+        final HiZPyramid hiz = renderingContext.occlusionPyramid;
+        if (hiz != null
+                && renderingContext.stereoEye == StereoEye.NONE) {
+            hiz.blocksTested.incrementAndGet();
+            final Point3D lo = boundingBox.p1, hi = boundingBox.p2;
+            double ax1 = Double.MAX_VALUE, ay1 = Double.MAX_VALUE;
+            double ax2 = -Double.MAX_VALUE, ay2 = -Double.MAX_VALUE;
+            double nearestW = -Double.MAX_VALUE;
+            boolean usable = true;
+            for (int c = 0; c < 8; c++) {
+                final double wx = (c & 1) != 0 ? hi.x : lo.x;
+                final double wy = (c & 2) != 0 ? hi.y : lo.y;
+                final double wz = (c & 4) != 0 ? hi.z : lo.z;
+                final double ccz = r6 * wx + r7 * wy + r8 * wz + t2;
+                if (ccz <= near) {
+                    usable = false;
+                    break;
+                }
+                final double ccx = r0 * wx + r1 * wy + r2 * wz + t0;
+                final double ccy = r3 * wx + r4 * wy + r5 * wz + t1;
+                final double sx = ((ccx / ccz) * scale) + centerX + stereo;
+                final double sy = ((ccy / ccz) * scale) + centerY;
+                if (sx < ax1) ax1 = sx;
+                if (sx > ax2) ax2 = sx;
+                if (sy < ay1) ay1 = sy;
+                if (sy > ay2) ay2 = sy;
+                final double w = 1d / ccz;
+                if (w > nearestW) nearestW = w;
+            }
+            if (usable && ax1 <= ax2 && ay1 <= ay2
+                    && hiz.occluded(ax1, ay1, ax2, ay2, nearestW)) {
+                hiz.blocksCulled.incrementAndGet();
+                return;
+            }
+        }
+        final double cullThreshold = renderingContext.subpixelCullingThreshold;
+        final int epoch = renderingContext.subpixelCullingEpoch;
+        final double rMinX = renderingContext.renderMinX;
+        final double rMaxX = renderingContext.renderMaxX;
+        final double rMinY = renderingContext.renderMinY;
+        final double rMaxY = renderingContext.renderMaxY;
+
+        for (int t = 0; t < triCount; t++) {
+            if (cullThreshold > 0 && cullEpoch[t] == epoch)
+                continue;
+
+            final int w = t * 9;
+            // Same expression order as TransformStack.transform.
+            final double x0 = world[w], y0 = world[w + 1], z0 = world[w + 2];
+            final double cx0 = r0 * x0 + r1 * y0 + r2 * z0 + t0;
+            final double cy0 = r3 * x0 + r4 * y0 + r5 * z0 + t1;
+            final double cz0 = r6 * x0 + r7 * y0 + r8 * z0 + t2;
+            final double x1 = world[w + 3], y1 = world[w + 4], z1 = world[w + 5];
+            final double cx1 = r0 * x1 + r1 * y1 + r2 * z1 + t0;
+            final double cy1 = r3 * x1 + r4 * y1 + r5 * z1 + t1;
+            final double cz1 = r6 * x1 + r7 * y1 + r8 * z1 + t2;
+            final double x2 = world[w + 6], y2 = world[w + 7], z2 = world[w + 8];
+            final double cx2 = r0 * x2 + r1 * y2 + r2 * z2 + t0;
+            final double cy2 = r3 * x2 + r4 * y2 + r5 * z2 + t1;
+            final double cz2 = r6 * x2 + r7 * y2 + r8 * z2 + t2;
+
+            final boolean in0 = cz0 > near;
+            final boolean in1 = cz1 > near;
+            final boolean in2 = cz2 > near;
+
+            if (!in0 && !in1 && !in2) {
+                cref[t] = -1;
+                continue;
+            }
+
+            final int v = t * 3;
+            if (!(in0 && in1 && in2)) {
+                clipAndQueue(t, v, slot, cx0, cy0, cz0, cx1, cy1, cz1,
+                        cx2, cy2, cz2, in0, in1, in2, near, cref,
+                        aggregator, renderingContext);
+                continue;
+            }
+
+            cref[t] = -1;
+            cz[v] = cz0;
+            cz[v + 1] = cz1;
+            cz[v + 2] = cz2;
+            // Same expression order as
+            // Vertex.calculateLocationRelativeToViewer (divide, scale,
+            // add center, add stereo offset).
+            final double sx0 = ((cx0 / cz0) * scale) + centerX + stereo;
+            final double sy0 = ((cy0 / cz0) * scale) + centerY;
+            final double sx1 = ((cx1 / cz1) * scale) + centerX + stereo;
+            final double sy1 = ((cy1 / cz1) * scale) + centerY;
+            final double sx2 = ((cx2 / cz2) * scale) + centerX + stereo;
+            final double sy2 = ((cy2 / cz2) * scale) + centerY;
+            px[v] = sx0;
+            py[v] = sy0;
+            px[v + 1] = sx1;
+            py[v + 1] = sy1;
+            px[v + 2] = sx2;
+            py[v + 2] = sy2;
+            final double triZ = (cz0 + cz1 + cz2) / 3;
+
+            final double minX = Math.min(sx0, Math.min(sx1, sx2));
+            final double maxX = Math.max(sx0, Math.max(sx1, sx2));
+            final double minY = Math.min(sy0, Math.min(sy1, sy2));
+            final double maxY = Math.max(sy0, Math.max(sy1, sy2));
+
+            // Publish the same per-slot state an object-backed triangle
+            // would have written (paint margins are 0 for mesh tris):
+            // comparator and tile binning then read plain fields.
+            handles[t].publishSlotState(slot, triZ, minY, maxY, minX, maxX);
+
+            // Subpixel verdict with per-triangle cache (same raw-span
+            // semantics as AbstractCoordinateShape).
+            if (cullThreshold > 0
+                    && maxX - minX < cullThreshold
+                    && maxY - minY < cullThreshold) {
+                cullEpoch[t] = epoch;
+                continue;
+            }
+
+            // Viewport cull (paint margins are 0 for mesh triangles).
+            if (maxX < rMinX || minX >= rMaxX || maxY < rMinY || minY >= rMaxY)
+                continue;
+
+            aggregator.queueShapeForRendering(handles[t]);
+        }
+    }
+
+    /**
+     * Near-plane clip for one straddling triangle, Sutherland-Hodgman
+     * over the three edges with the exact interpolation expressions of
+     * {@code AbstractCoordinateShape.interpolateAtPlane}. Output goes to
+     * the slot's grow-only clip store; the handle is queued with a packed
+     * reference.
+     */
+    private void clipAndQueue(final int t, final int v, final int slot,
+                              final double cx0, final double cy0, final double cz0,
+                              final double cx1, final double cy1, final double cz1,
+                              final double cx2, final double cy2, final double cz2,
+                              final boolean in0, final boolean in1, final boolean in2,
+                              final double near, final int[] cref,
+                              final RenderAggregator aggregator,
+                              final RenderingContext renderingContext) {
+        double[] store = clipStore[slot];
+        int used = clipUsed[slot];
+        if (used + CLIP_ENTRY > store.length) {
+            final double[] grown = new double[store.length * 2];
+            System.arraycopy(store, 0, grown, 0, used);
+            store = grown;
+            clipStore[slot] = grown;
+            final double[] grownTtd = new double[grown.length / CLIP_ENTRY];
+            System.arraycopy(clipTtd[slot], 0, grownTtd, 0, clipTtd[slot].length);
+            clipTtd[slot] = grownTtd;
+        }
+        final int base = used;
+        clipTtd[slot][base / CLIP_ENTRY] = origTtd(t);
+
+        final double scale = renderingContext.projectionScale;
+        final double centerX = renderingContext.centerCoordinate.x;
+        final double centerY = renderingContext.centerCoordinate.y;
+        final double stereo = renderingContext.stereoViewportOffsetX;
+
+        final double[] cx = {cx0, cx1, cx2};
+        final double[] cy = {cy0, cy1, cy2};
+        final double[] czz = {cz0, cz1, cz2};
+        final boolean[] in = {in0, in1, in2};
+        final int uvi = t * 6;
+
+        int n = 0;
+        double sumZ = 0;
+        for (int i = 0; i < 3; i++) {
+            final int j = (i + 1) % 3;
+            final boolean currentIn = in[i];
+            final boolean nextIn = in[j];
+            if (currentIn) {
+                store[used++] = cx[i];
+                store[used++] = cy[i];
+                store[used++] = czz[i];
+                store[used++] = uv[uvi + i * 2];
+                store[used++] = uv[uvi + i * 2 + 1];
+                // setCameraSpaceCoordinate expression, same order
+                store[used++] = ((cx[i] / czz[i]) * scale) + centerX + stereo;
+                store[used++] = ((cy[i] / czz[i]) * scale) + centerY;
+                n++;
+                sumZ += czz[i];
+            }
+            if (currentIn != nextIn) {
+                final double tt = (near - czz[i]) / (czz[j] - czz[i]);
+                final double ix = cx[i] + (cx[j] - cx[i]) * tt;
+                final double iy = cy[i] + (cy[j] - cy[i]) * tt;
+                final double iz = czz[i] + (czz[j] - czz[i]) * tt;
+                store[used++] = ix;
+                store[used++] = iy;
+                store[used++] = iz;
+                store[used++] = uv[uvi + i * 2]
+                        + (uv[uvi + j * 2] - uv[uvi + i * 2]) * tt;
+                store[used++] = uv[uvi + i * 2 + 1]
+                        + (uv[uvi + j * 2 + 1] - uv[uvi + i * 2 + 1]) * tt;
+                store[used++] = ((ix / iz) * scale) + centerX + stereo;
+                store[used++] = ((iy / iz) * scale) + centerY;
+                n++;
+                sumZ += iz;
+            }
+        }
+
+        // Degenerate sliver: fewer loop points than a renderable triangle.
+        if (n < 3) {
+            cref[t] = -1;
+            return;
+        }
+        clipUsed[slot] = used;
+        cref[t] = (base << 3) | n;
+        // Object path averages Z and derives bounds over the clipped loop
+        double cMinX = Double.MAX_VALUE, cMaxX = -Double.MAX_VALUE;
+        double cMinY = Double.MAX_VALUE, cMaxY = -Double.MAX_VALUE;
+        for (int i = 0; i < n; i++) {
+            final double sx = store[base + i * CLIP_STRIDE + 5];
+            final double sy = store[base + i * CLIP_STRIDE + 6];
+            if (sx < cMinX) cMinX = sx;
+            if (sx > cMaxX) cMaxX = sx;
+            if (sy < cMinY) cMinY = sy;
+            if (sy > cMaxY) cMaxY = sy;
+        }
+        handles[t].publishSlotState(slot, sumZ / n, cMinY, cMaxY, cMinX, cMaxX);
+        aggregator.queueShapeForRendering(handles[t]);
+    }
+
+    // ---- handle-facing accessors (package-private) ----
+
+    double camZ(final int slot, final int tri, final int vertex) {
+        return camZ[slot][tri * 3 + vertex];
+    }
+
+    Texture texture(final int tri) {
+        return textures[tri];
+    }
+
+    boolean backfaceCull() {
+        return backfaceCull;
+    }
+
+    int clipOffset(final int slot, final int tri) {
+        final int ref = clipRef[slot][tri];
+        return ref < 0 ? -1 : ref >> 3;
+    }
+
+    int clipCount(final int slot, final int tri) {
+        return clipRef[slot][tri] & 7;
+    }
+
+    double[] clipStore(final int slot) {
+        return clipStore[slot];
+    }
+
+    double clipTtd(final int slot, final int clipOffset) {
+        return clipTtd[slot][clipOffset / CLIP_ENTRY];
+    }
+
+    /**
+     * The triangle's UV perimeter, with the exact expression and
+     * accumulation order of {@code TexturedTriangle}'s
+     * computeTotalTextureDistance: d(0,1) + d(0,2) + d(1,2).
+     */
+    double origTtd(final int tri) {
+        final int u = tri * 6;
+        final double d1 = Math.sqrt(
+                ((uv[u] - uv[u + 2]) * (uv[u] - uv[u + 2]))
+                        + ((uv[u + 1] - uv[u + 3]) * (uv[u + 1] - uv[u + 3])));
+        final double d2 = Math.sqrt(
+                ((uv[u] - uv[u + 4]) * (uv[u] - uv[u + 4]))
+                        + ((uv[u + 1] - uv[u + 5]) * (uv[u + 1] - uv[u + 5])));
+        final double d3 = Math.sqrt(
+                ((uv[u + 2] - uv[u + 4]) * (uv[u + 2] - uv[u + 4]))
+                        + ((uv[u + 3] - uv[u + 5]) * (uv[u + 3] - uv[u + 5])));
+        return d1 + d2 + d3;
+    }
+
+    /**
+     * Loads one unclipped triangle vertex (screen + UV) into the scratch
+     * carriers, values exactly as computed at transform time.
+     */
+    void loadScreenVertex(final Point2D screen, final Point2D uvOut,
+                          final int slot, final int tri, final int vertex,
+                          final RenderingContext ctx) {
+        final int v = tri * 3 + vertex;
+        screen.x = projX[slot][v];
+        screen.y = projY[slot][v];
+        uvOut.x = uv[tri * 6 + vertex * 2];
+        uvOut.y = uv[tri * 6 + vertex * 2 + 1];
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java
new file mode 100644 (file)
index 0000000..3b138bb
--- /dev/null
@@ -0,0 +1,28 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Textured triangle rendering with perspective-correct UV mapping.
+ *
+ * <p>Textured triangles apply 2D textures to 3D triangles using UV coordinates.
+ * Quake-style subdivided perspective correction (per-scanline recovery of exact
+ * u/v from linearly interpolated u/z, v/z, 1/z) keeps textures correct at any
+ * triangle size, so no tessellation is needed.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle} -
+ *       The base textured triangle with perspective-correct scanline rendering</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.PerspectiveBorderInterpolator} -
+ *       Edge interpolation of u/z, v/z, 1/z gradients</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.PolygonBorderInterpolator} -
+ *       Affine edge interpolation (fallback path)</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java
new file mode 100644 (file)
index 0000000..a0d8468
--- /dev/null
@@ -0,0 +1,108 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.Billboard;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+import java.awt.Font;
+
+/**
+ * A text label rendered as a billboard texture that always faces the camera.
+ *
+ * <p>This shape renders a single line of text onto a {@link Texture} using the cell metrics
+ * defined in {@link TextCanvas} ({@link TextCanvas#FONT_CHAR_WIDTH_TEXTURE_PIXELS},
+ * {@link TextCanvas#FONT_CHAR_HEIGHT_TEXTURE_PIXELS}), then displays the texture as a
+ * forward-oriented billboard via its {@link Billboard} superclass. The result
+ * is a text label that remains readable from any viewing angle.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a red text label at position (0, -50, 300)
+ * ForwardOrientedTextBlock label = new ForwardOrientedTextBlock(
+ *     new Point3D(0, -50, 300),
+ *     1.0,
+ *     2,
+ *     "Hello, World!",
+ *     Color.RED
+ * );
+ * shapeCollection.addShape(label);
+ * }</pre>
+ *
+ * @see Billboard
+ * @see TextCanvas
+ * @see Texture
+ */
+public class ForwardOrientedTextBlock extends Billboard {
+
+    /**
+     * The font used to render the label text. Matches the SDF text
+     * pipeline's family (Liberation Mono Bold, Courier-metric-compatible)
+     * at the cell size used by {@link TextCanvas}.
+     */
+    private static final Font FONT = createFont();
+
+    private static Font createFont() {
+        final Font font = new Font("Liberation Mono", Font.BOLD, 30);
+        if (!font.getFamily().toLowerCase().contains("liberation")) {
+            return new Font("Monospaced", Font.BOLD, 30);
+        }
+        return font;
+    }
+
+    /**
+     * Creates a new forward-oriented text block at the given 3D position.
+     *
+     * @param point            the 3D position where the text label is placed
+     * @param scale            the scale factor controlling the rendered size of the text
+     * @param maxUpscaleFactor the maximum mipmap upscale factor for the backing texture
+     * @param text             the text string to render
+     * @param textColor        the color of the rendered text
+     */
+    public ForwardOrientedTextBlock(final Point3D point, final double scale,
+                                    final int maxUpscaleFactor, final String text,
+                                    final eu.svjatoslav.aukio.e3d.renderer.raster.Color textColor) {
+        super(point, scale, getTexture(text, maxUpscaleFactor, textColor));
+
+    }
+
+    /**
+     * Creates a {@link Texture} containing the rendered text string.
+     *
+     * <p>The texture dimensions are calculated from the text length and the cell metrics
+     * defined in {@link TextCanvas}. Each character is drawn individually at the appropriate
+     * horizontal offset.</p>
+     *
+     * @param text             the text string to render into the texture
+     * @param maxUpscaleFactor the maximum mipmap upscale factor for the texture
+     * @param textColor        the color of the rendered text
+     * @return a new {@link Texture} containing the rendered text
+     */
+    public static Texture getTexture(final String text,
+                                     final int maxUpscaleFactor,
+                                     final eu.svjatoslav.aukio.e3d.renderer.raster.Color textColor) {
+
+        final Texture texture = new Texture(text.length()
+                * TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS, TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS,
+                maxUpscaleFactor);
+
+        // Put blue background to test if texture has correct size
+        // texture.graphics.setColor(Color.BLUE);
+        // texture.graphics.fillRect(0, 0, texture.primaryBitmap.width,
+        //    texture.primaryBitmap.width);
+
+        texture.graphics.setFont(FONT);
+        texture.graphics.setColor(textColor.toAwtColor());
+
+        for (int c = 0; c < text.length(); c++)
+            texture.graphics.drawChars(new char[]{text.charAt(c),}, 0, 1,
+                    (c * TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS),
+                    (int) (TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.45));
+
+        return texture;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/Graph.java
new file mode 100644 (file)
index 0000000..a54809b
--- /dev/null
@@ -0,0 +1,180 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+
+import java.util.List;
+
+/**
+ * A 2D graph visualization rendered in 3D space.
+ *
+ * <p>Plots a series of {@link Point2D} data points as a connected line graph, overlaid on a
+ * grid with horizontal and vertical grid lines, axis labels, and a title. The graph is
+ * rendered in the XY plane at the specified 3D location, with all dimensions scaled by
+ * a configurable scale factor.</p>
+ *
+ * <p>The graph uses the following default configuration:</p>
+ * <ul>
+ *   <li>X-axis range: {@code 0} to {@code 20} (world units before scaling)</li>
+ *   <li>Y-axis range: {@code -2} to {@code 2}</li>
+ *   <li>Grid spacing: {@code 0.5} in both horizontal and vertical directions</li>
+ *   <li>Grid color: semi-transparent blue ({@code rgba(100, 100, 250, 100)})</li>
+ *   <li>Plot color: semi-transparent red ({@code rgba(255, 0, 0, 100)})</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Prepare data points
+ * List<Point2D> data = new ArrayList<>();
+ * for (double x = 0; x <= 20; x += 0.1) {
+ *     data.add(new Point2D(x, Math.sin(x)));
+ * }
+ *
+ * // Create a graph at position (0, 0, 500) with scale factor 10
+ * Graph graph = new Graph(10.0, data, "sin(x)", new Point3D(0, 0, 500));
+ *
+ * // Add to the scene
+ * shapeCollection.addShape(graph);
+ * }</pre>
+ *
+ * @see Line
+ * @see TextCanvas
+ * @see AbstractCompositeShape
+ */
+public class Graph extends AbstractCompositeShape {
+
+    /** The width of the graph in unscaled world units. */
+    private final double width;
+    /** The minimum Y-axis value. */
+    private final double yMin;
+    /** The maximum Y-axis value. */
+    private final double yMax;
+    /** The spacing between vertical grid lines along the X-axis. */
+    private final double horizontalStep;
+    /** The spacing between horizontal grid lines along the Y-axis. */
+    private final double verticalStep;
+    /** The color used for grid lines. */
+    private final Color gridColor;
+    /** The width of grid lines in world units (after scaling). */
+    private final double lineWidth;
+    /** The color used for the data plot line. */
+    private final Color plotColor;
+
+    /**
+     * Creates a new graph visualization at the specified 3D location.
+     *
+     * <p>The graph is constructed with grid lines, axis labels, plotted data, and a title
+     * label. All spatial dimensions are multiplied by the given scale factor.</p>
+     *
+     * @param scale    the scale factor applied to all spatial dimensions of the graph
+     * @param data     the list of 2D data points to plot; consecutive points are connected by lines
+     * @param label    the title text displayed above the graph
+     * @param location the 3D position of the graph's origin in the scene
+     */
+    public Graph(final double scale, final List<Point2D> data,
+                 final String label, final Point3D location) {
+        super(location);
+
+        width = 20;
+
+        yMin = -2;
+        yMax = 2;
+
+        horizontalStep = 0.5;
+        verticalStep = 0.5;
+
+        gridColor = new Color(100, 100, 250, 100);
+
+        lineWidth = 0.1 * scale;
+        plotColor = new Color(255, 0, 0, 100);
+
+        addVerticalLines(scale);
+        addXLabels(scale);
+        addHorizontalLinesAndLabels(scale);
+        plotData(scale, data);
+
+        final Point3D labelLocation = new Point3D(width / 2, yMax + 0.5, 0)
+                .multiply(scale);
+
+        final TextCanvas labelCanvas = new TextCanvas(new Transform(
+                labelLocation), label, Color.WHITE, Color.TRANSPARENT);
+
+        addShape(labelCanvas);
+    }
+
+    private void addHorizontalLinesAndLabels(final double scale) {
+        for (double y = yMin; y <= yMax; y += verticalStep) {
+
+            final Point3D p1 = new Point3D(0, y, 0).multiply(scale);
+
+            final Point3D p2 = new Point3D(width, y, 0).multiply(scale);
+
+            final Line line = new Line(p1, p2, gridColor, lineWidth);
+
+            addShape(line);
+
+            final Point3D labelLocation = new Point3D(-0.5, y, 0)
+                    .multiply(scale);
+
+            final TextCanvas label = new TextCanvas(
+                    new Transform(labelLocation), String.valueOf(y),
+                    Color.WHITE, Color.TRANSPARENT);
+
+            addShape(label);
+
+        }
+    }
+
+    private void addVerticalLines(final double scale) {
+        for (double x = 0; x <= width; x += horizontalStep) {
+
+            final Point3D p1 = new Point3D(x, yMin, 0).multiply(scale);
+            final Point3D p2 = new Point3D(x, yMax, 0).multiply(scale);
+
+            final Line line = new Line(p1, p2, gridColor, lineWidth);
+
+            addShape(line);
+
+        }
+    }
+
+    private void addXLabels(final double scale) {
+        for (double x = 0; x <= width; x += horizontalStep * 2) {
+            final Point3D labelLocation = new Point3D(x, yMin - 0.4, 0)
+                    .multiply(scale);
+
+            final TextCanvas label = new TextCanvas(
+                    new Transform(labelLocation), String.valueOf(x),
+                    Color.WHITE, Color.TRANSPARENT);
+
+            addShape(label);
+        }
+    }
+
+    private void plotData(final double scale, final List<Point2D> data) {
+        Point3D previousPoint = null;
+        for (final Point2D point : data) {
+
+            final Point3D p3d = new Point3D(point.x, point.y, 0).multiply(scale);
+
+            if (previousPoint != null) {
+
+                final Line line = new Line(previousPoint, p3d, plotColor,
+                        0.4 * scale);
+
+                addShape(line);
+            }
+
+            previousPoint = p3d;
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightSourceMarker.java
new file mode 100755 (executable)
index 0000000..0ef6094
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A visual marker that indicates a light source position in the 3D scene.
+ *
+ * <p>Rendered as a glowing point that provides a clear, lightweight visual
+ * indicator useful for debugging light placement in the scene.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Place a yellow light source marker at position (100, -50, 200)
+ * LightSourceMarker marker = new LightSourceMarker(
+ *     new Point3D(100, -50, 200),
+ *     Color.YELLOW
+ * );
+ * shapeCollection.addShape(marker);
+ * }</pre>
+ *
+ * @see GlowingPoint
+ * @see AbstractCompositeShape
+ */
+public class LightSourceMarker extends AbstractCompositeShape {
+
+    /**
+     * Creates a new light source marker at the specified location.
+     *
+     * @param location the 3D position of the marker in the scene
+     * @param color    the color of the glowing point
+     */
+    public LightSourceMarker(final Point3D location, final Color color) {
+        super(location);
+        addShape(new GlowingPoint(new Point3D(0, 0, 0), 15, color));
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
new file mode 100644 (file)
index 0000000..9aba6d4
--- /dev/null
@@ -0,0 +1,176 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.gi.LightmappedTriangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Composite shape whose solid polygons can render as lightmapped
+ * triangles for the global illumination system.
+ *
+ * <p><b>Lightmapping:</b> when enabled, every {@link SolidPolygon}
+ * (including those inside nested composites, which are flattened) is
+ * wrapped as a {@link LightmappedTriangle} with its own lightmap texture,
+ * and the GI system paints light across its surface instead of leaving
+ * it a single flat color. Rebuild is automatic via the normal
+ * cache-invalidation path.</p>
+ *
+ * <p><b>Ordering:</b> none required — the engine's z-buffer resolves
+ * visibility per pixel, so this class needs no spatial ordering
+ * structure of its own. Nested composites are flattened assuming
+ * identity transforms (vertices are used in the composite's local
+ * space); animate transforms above this composite, not inside it.</p>
+ *
+ * @see LightmappedTriangle the per-fragment lightmap carrier
+ * @see AbstractCompositeShape the base composite (fan-triangulates
+ *      N-vertex polygons when building the render list)
+ */
+public class LightmappedCompositeShape extends AbstractCompositeShape {
+
+    /**
+     * When true, the composite's polygons render as
+     * {@link LightmappedTriangle}s: each polygon gets its own lightmap
+     * texture and the GI system paints light across its surface.
+     */
+    private boolean lightmappingEnabled;
+
+    /** World units per lightmap texel (smaller = finer GI detail). */
+    private double lightmapUnitsPerTexel = 12.0;
+
+    /**
+     * Replaces solid polygons with lightmapped wrappers when lightmapping
+     * is enabled; otherwise returns the list unchanged.
+     *
+     * @param renderList the render list built from the shape registry
+     * @return lightmapped wrappers + non-polygon passthrough shapes
+     */
+    @Override
+    protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
+        if (!lightmappingEnabled)
+            return renderList;
+
+        final List<AbstractShape> result = new ArrayList<>(renderList.size());
+        for (final AbstractShape shape : renderList) {
+            if (shape instanceof SolidPolygon) {
+                wrapLightmapped((SolidPolygon) shape, result);
+            } else if (shape instanceof AbstractCompositeShape) {
+                // Flatten nested composites. Assumes identity transforms
+                // on the nested composite chain.
+                for (final SolidPolygon polygon
+                        : ((AbstractCompositeShape) shape).extractSolidPolygons())
+                    wrapLightmapped(polygon, result);
+            } else {
+                result.add(shape);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Enables or disables lightmapping. When enabled, the composite's
+     * polygons render as {@link LightmappedTriangle}s with per-polygon
+     * lightmap textures filled by the global illumination system.
+     * Rebuilds the render list on the next frame.
+     *
+     * @param enabled true to render polygons lightmapped
+     */
+    public void setLightmappingEnabled(final boolean enabled) {
+        if (lightmappingEnabled != enabled) {
+            lightmappingEnabled = enabled;
+            setCacheNeedsRebuild(true);
+        }
+    }
+
+    /**
+     * Returns whether lightmapping is enabled.
+     *
+     * @return true when polygons render as lightmapped triangles
+     */
+    public boolean isLightmappingEnabled() {
+        return lightmappingEnabled;
+    }
+
+    /**
+     * Sets the lightmap resolution. Default 12 world units per texel:
+     * a 100-unit wall cell gets an 8x8 lightmap. Halving the units
+     * quadruples the GI tracing work; finer texels are the ONLY way to
+     * smoother shadow edges (there is no upsampling). Takes effect on the
+     * next render list rebuild.
+     *
+     * @param unitsPerTexel world units per texel
+     */
+    public void setLightmapUnitsPerTexel(final double unitsPerTexel) {
+        lightmapUnitsPerTexel = unitsPerTexel;
+        setCacheNeedsRebuild(true);
+    }
+
+    /**
+     * Fan-triangulates a polygon (triangles pass through as-is) and wraps
+     * each triangle into a lightmapped textured triangle with the same
+     * geometry, color and culling. The initial texture is dim; the GI
+     * system converges it to full lighting within seconds.
+     *
+     * @param polygon the polygon to wrap (any vertex count >= 3)
+     * @param out     the list receiving the lightmapped triangles
+     */
+    private void wrapLightmapped(final SolidPolygon polygon, final List<AbstractShape> out) {
+        final int vertexCount = polygon.getVertexCount();
+        if (vertexCount == 3) {
+            out.add(wrapTriangle(polygon));
+            return;
+        }
+        // Fan: anchor vertex 0, then consecutive pairs
+        final Point3D anchor = polygon.vertices.get(0).coordinate;
+        for (int i = 1; i + 1 < vertexCount; i++) {
+            final SolidPolygon triangle = new SolidPolygon(
+                    anchor,
+                    polygon.vertices.get(i).coordinate,
+                    polygon.vertices.get(i + 1).coordinate,
+                    polygon.getColor());
+            triangle.setShadingEnabled(polygon.isShadingEnabled());
+            triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled());
+            triangle.setMouseInteractionController(polygon.mouseInteractionController);
+            out.add(wrapTriangle(triangle));
+        }
+    }
+
+    /**
+     * Wraps one triangle into a lightmapped textured triangle with the
+     * same geometry, color and culling.
+     */
+    private AbstractCoordinateShape wrapTriangle(final SolidPolygon polygon) {
+        final Point3D a = polygon.vertices.get(0).coordinate;
+        final Point3D b = polygon.vertices.get(1).coordinate;
+        final Point3D c = polygon.vertices.get(2).coordinate;
+
+        // Normal: right-handed cross(b-a, c-a).
+        final double e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
+        final double e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
+        double nx = e1y * e2z - e1z * e2y;
+        double ny = e1z * e2x - e1x * e2z;
+        double nz = e1x * e2y - e1y * e2x;
+        final double len = Math.sqrt(nx * nx + ny * ny + nz * nz);
+        if (len > 0) {
+            nx /= len;
+            ny /= len;
+            nz /= len;
+        }
+
+        final LightmappedTriangle triangle = new LightmappedTriangle(
+                a, b, c, polygon.getColor(), lightmapUnitsPerTexel,
+                nx, ny, nz);
+        triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled());
+        triangle.setMouseInteractionController(polygon.mouseInteractionController);
+        return triangle;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/TexturedRectangle.java
new file mode 100644 (file)
index 0000000..89e8d79
--- /dev/null
@@ -0,0 +1,180 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+
+/**
+ * A rectangular shape with texture mapping, composed of two textured triangles.
+ *
+ * <p>This composite shape creates a textured rectangle in 3D space by splitting it into
+ * two {@link TexturedTriangle} triangles that share a common {@link Texture}. The rectangle
+ * is centered at the origin of its local coordinate system, with configurable world-space
+ * dimensions and independent texture resolution.</p>
+ *
+ * <p>The contained {@link Texture} object is accessible via {@link #getTexture()}, allowing
+ * dynamic rendering to the texture surface (e.g., drawing text, images, or procedural content)
+ * after construction.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a 200x100 textured rectangle at position (0, 0, 300)
+ * Transform transform = new Transform(new Point3D(0, 0, 300));
+ * TexturedRectangle rect = new TexturedRectangle(transform, 200, 100, 2);
+ *
+ * // Draw onto the texture dynamically
+ * Texture tex = rect.getTexture();
+ * tex.graphics.setColor(java.awt.Color.RED);
+ * tex.graphics.fillRect(0, 0, 50, 50);
+ *
+ * // Add to the scene
+ * shapeCollection.addShape(rect);
+ * }</pre>
+ *
+ * @see TexturedTriangle
+ * @see Texture
+ * @see AbstractCompositeShape
+ */
+public class TexturedRectangle extends AbstractCompositeShape {
+
+    /** Top-left corner position in local 3D coordinates. */
+    public Point3D topLeft;
+    /** Top-right corner position in local 3D coordinates. */
+    public Point3D topRight;
+    /** Bottom-right corner position in local 3D coordinates. */
+    public Point3D bottomRight;
+    /** Bottom-left corner position in local 3D coordinates. */
+    public Point3D bottomLeft;
+    /** Top-left corner mapping in texture coordinates (pixels). */
+    public Point2D textureTopLeft;
+    /** Top-right corner mapping in texture coordinates (pixels). */
+    public Point2D textureTopRight;
+    /** Bottom-right corner mapping in texture coordinates (pixels). */
+    public Point2D textureBottomRight;
+    /** Bottom-left corner mapping in texture coordinates (pixels). */
+    public Point2D textureBottomLeft;
+    private Texture texture;
+
+    /**
+     * Creates a textured rectangle with only a transform, without initializing geometry.
+     *
+     * <p>After construction, call {@link #initialize(double, double, int, int, int)} to
+     * set up the rectangle's dimensions, texture, and triangle geometry.</p>
+     *
+     * @param transform the position and orientation of this rectangle in the scene
+     */
+    public TexturedRectangle(final Transform transform) {
+        super(transform);
+    }
+
+    /**
+     * Creates a textured rectangle where the texture resolution matches the world-space size.
+     *
+     * <p>This is a convenience constructor equivalent to calling
+     * {@link #TexturedRectangle(Transform, int, int, int, int, int)} with
+     * {@code textureWidth = width} and {@code textureHeight = height}.</p>
+     *
+     * @param transform         the position and orientation of this rectangle in the scene
+     * @param width             the width of the rectangle in world units (also used as texture width in pixels)
+     * @param height            the height of the rectangle in world units (also used as texture height in pixels)
+     * @param maxTextureUpscale the maximum mipmap upscale factor for the texture
+     */
+    public TexturedRectangle(final Transform transform, final int width,
+                             final int height, final int maxTextureUpscale) {
+        this(transform, width, height, width, height, maxTextureUpscale);
+    }
+
+    /**
+     * Creates a fully initialized textured rectangle with independent world-space size and texture resolution.
+     *
+     * @param transform         the position and orientation of this rectangle in the scene
+     * @param width             the width of the rectangle in world units
+     * @param height            the height of the rectangle in world units
+     * @param textureWidth      the width of the backing texture in pixels
+     * @param textureHeight     the height of the backing texture in pixels
+     * @param maxTextureUpscale the maximum mipmap upscale factor for the texture
+     */
+    public TexturedRectangle(final Transform transform, final int width,
+                             final int height, final int textureWidth, final int textureHeight,
+                             final int maxTextureUpscale) {
+
+        super(transform);
+
+        initialize(width, height, textureWidth, textureHeight,
+                maxTextureUpscale);
+    }
+
+    /**
+     * Returns the backing texture for this rectangle.
+     *
+     * <p>The returned {@link Texture} can be used to draw dynamic content onto the
+     * rectangle's surface via its {@code graphics} field (a {@link java.awt.Graphics2D} instance).</p>
+     *
+     * @return the texture mapped onto this rectangle
+     */
+    public Texture getTexture() {
+        return texture;
+    }
+
+    /**
+     * Initializes the rectangle geometry, texture, and the two constituent textured triangles.
+     *
+     * <p>The rectangle is centered at the local origin: corners span from
+     * {@code (-width/2, -height/2, 0)} to {@code (width/2, height/2, 0)}.
+     * Two {@link TexturedTriangle} triangles are created to cover the full rectangle,
+     * sharing a single {@link Texture} instance.</p>
+     *
+     * @param width             the width of the rectangle in world units
+     * @param height            the height of the rectangle in world units
+     * @param textureWidth      the width of the backing texture in pixels
+     * @param textureHeight     the height of the backing texture in pixels
+     * @param maxTextureUpscale the maximum mipmap upscale factor for the texture
+     */
+    public void initialize(final double width, final double height,
+                           final int textureWidth, final int textureHeight,
+                           final int maxTextureUpscale) {
+
+        topLeft = new Point3D(-width / 2, -height / 2, 0);
+        topRight = new Point3D(width / 2, -height / 2, 0);
+        bottomRight = new Point3D(width / 2, height / 2, 0);
+        bottomLeft = new Point3D(-width / 2, height / 2, 0);
+
+        texture = new Texture(textureWidth, textureHeight, maxTextureUpscale);
+
+        textureTopRight = new Point2D(textureWidth, 0);
+        textureTopLeft = new Point2D(0, 0);
+        textureBottomRight = new Point2D(textureWidth, textureHeight);
+        textureBottomLeft = new Point2D(0, textureHeight);
+
+
+
+
+        final TexturedTriangle texturedPolygon1 = new TexturedTriangle(
+                new Vertex(topLeft, textureTopLeft),
+                new Vertex(topRight, textureTopRight),
+                new Vertex(bottomRight, textureBottomRight), texture);
+
+        texturedPolygon1
+                .setMouseInteractionController(mouseInteractionController);
+
+        final TexturedTriangle texturedPolygon2 = new TexturedTriangle(
+                new Vertex(topLeft, textureTopLeft),
+                new Vertex(bottomLeft, textureBottomLeft),
+                new Vertex(bottomRight, textureBottomRight), texture);
+
+        texturedPolygon2
+                .setMouseInteractionController(mouseInteractionController);
+
+        addShape(texturedPolygon1);
+        addShape(texturedPolygon2);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
new file mode 100644 (file)
index 0000000..46724bb
--- /dev/null
@@ -0,0 +1,1295 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.BspTree;
+import eu.svjatoslav.aukio.e3d.geometry.Frustum;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.ViewSpaceTracker;
+import eu.svjatoslav.aukio.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ParallelTransformCoordinator;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A composite shape that groups multiple sub-shapes into a single logical unit.
+ *
+ * <p>Use {@code AbstractCompositeShape} to build complex 3D objects by combining
+ * primitive shapes (lines, polygons, textured polygons) into a group that can be
+ * positioned, rotated, and manipulated as one entity. Sub-shapes can be organized
+ * into named groups for selective visibility toggling.</p>
+ *
+ * <p><b>Usage example - creating a custom composite shape:</b></p>
+ * <pre>{@code
+ * // Create a composite shape at position (0, 0, 200)
+ * AbstractCompositeShape myObject = new AbstractCompositeShape(
+ *     new Point3D(0, 0, 200)
+ * );
+ *
+ * // Add sub-shapes
+ * myObject.addShape(new Line(
+ *     new Point3D(-50, 0, 0), new Point3D(50, 0, 0),
+ *     Color.RED, 2.0
+ * ));
+ *
+ * // Add shapes to a named group for toggling visibility
+ * myObject.addShape(labelShape, "labels");
+ * myObject.hideGroup("labels");  // hide all shapes in "labels" group
+ * myObject.showGroup("labels");  // show them again
+ *
+ * // Add to scene
+ * viewPanel.getRootShapeCollection().addShape(myObject);
+ * }</pre>
+ *
+ * <p><b>Perspective-correct texturing:</b></p>
+ * <p>Textured polygons are rendered with Quake-style perspective-correct scanline
+ * mapping ({@code TexturedTriangle}), so no screen-size tessellation is needed.</p>
+ *
+ * <p><b>Extending this class:</b></p>
+ * <p>Override {@link #beforeTransformHook} to customize shape appearance or behavior
+ * on each frame (e.g., animations, dynamic geometry updates).</p>
+ *
+ * @see SubShape wrapper for individual sub-shapes with group and visibility support
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class
+ */
+public class AbstractCompositeShape extends AbstractShape {
+    /**
+     * Source-of-truth registry of all sub-shapes added to this composite.
+     *
+     * <p>Each sub-shape is wrapped with its group identifier and visibility state.
+     * Shapes are stored in insertion order and remain in this collection even when
+     * hidden (visibility state toggles instead of removal).</p>
+     *
+     * <p><b>Performance note:</b> This list is NOT processed for every frame.
+     * Instead, it serves as the authoritative source from which {@link #cachedRenderList}
+     * is compiled whenever the cache becomes invalid (see {@link #cacheNeedsRebuild}).
+     * Only modifications to this registry (add/remove/show/hide) trigger cache rebuild.</p>
+     *
+     * @see #cachedRenderList the frame-optimized cache derived from this registry
+     * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt
+     */
+    private final List<SubShape> subShapesRegistry = new ArrayList<>();
+
+    /**
+     * Tracks the distance and angle between the camera and this shape.
+     * Used e.g. by TextCanvas for distance-based rendering mode selection.
+     */
+    private final ViewSpaceTracker viewSpaceTracker;
+
+    /**
+     * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}.
+     *
+     * <p>This list is processed during every frame in the {@link #transform} method.
+     * It contains:</p>
+     * <ul>
+     *   <li>Shapes passing through directly (Line, TexturedTriangle, ...)</li>
+     *   <li>Solid polygons with more than 3 vertices - fan-triangulated</li>
+     * </ul>
+     *
+     * <p><b>Caching strategy:</b> The list is rebuilt only when
+     * {@link #cacheNeedsRebuild} is true, avoiding per-frame reconstruction
+     * overhead.</p>
+     *
+     * @see #subShapesRegistry the source registry this cache is derived from
+     * @see #cacheNeedsRebuild the flag that triggers cache regeneration
+     */
+    private List<AbstractShape> cachedRenderList = new ArrayList<>();
+
+    /**
+     * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}.
+     *
+     * <p>Set to {@code true} when:</p>
+     * <ul>
+     *   <li>A shape is added via {@link #addShape}</li>
+     *   <li>A shape is removed via {@link #removeGroup}</li>
+     *   <li>Group visibility changes via {@link #showGroup} or {@link #hideGroup}</li>
+     * </ul>
+     *
+     * <p>Set to {@code false} after {@link #rebuildRenderList} completes the cache rebuild.</p>
+     *
+     * <p>This flag enables the performance optimization of avoiding per-frame list
+     * reconstruction - the registry is only re-processed when something actually changed.</p>
+     *
+     * @see #subShapesRegistry the source data that may need reprocessing
+     * @see #cachedRenderList the cache that gets rebuilt when this flag is true
+     */
+    private boolean cacheNeedsRebuild = true;
+
+    /**
+     * Flag indicating this composite is the root scene container (ShapeCollection's root).
+     *
+     * <p>Set via {@link #setRootComposite(boolean)} by ShapeCollection.</p>
+     */
+    private boolean isRootComposite = false;
+
+    /**
+     * The position and orientation transform for this composite shape.
+     * Applied to all sub-shapes during the rendering transform pass.
+     */
+    private Transform transform;
+
+    /**
+     * Creates a composite shape at the world origin with no rotation.
+     */
+    public AbstractCompositeShape() {
+        this(new Transform());
+    }
+
+    /**
+     * Creates a composite shape at the specified location with no rotation.
+     *
+     * @param location the position in world space
+     */
+    public AbstractCompositeShape(final Point3D location) {
+        this(new Transform(location));
+    }
+
+    /**
+     * Creates a composite shape with the specified transform (position and orientation).
+     *
+     * @param transform the initial transform defining position and rotation
+     */
+    public AbstractCompositeShape(final Transform transform) {
+        this.transform = transform;
+        viewSpaceTracker = new ViewSpaceTracker();
+    }
+
+    /**
+     * Adds a sub-shape to this composite shape without a group identifier.
+     *
+     * @param shape the shape to add
+     */
+    public void addShape(final AbstractShape shape) {
+        addShape(shape, null);
+    }
+
+    /**
+     * Adds a sub-shape to this composite shape with an optional group identifier.
+     *
+     * <p>Grouped shapes can be shown, hidden, or removed together using
+     * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.</p>
+     *
+     * @param shape   the shape to add
+     * @param groupId the group identifier, or {@code null} for ungrouped shapes
+     */
+    public void addShape(final AbstractShape shape, final String groupId) {
+        subShapesRegistry.add(new SubShape(shape, groupId, true));
+        cacheNeedsRebuild = true;
+    }
+
+    /**
+     * This method should be overridden by anyone wanting to customize the shape
+     * before it is rendered.
+     *
+     * @param transformPipe the current transform stack
+     * @param context       the rendering context for the current frame
+     */
+    public void beforeTransformHook(final TransformStack transformPipe,
+                                    final RenderingContext context) {
+    }
+
+    /**
+     * Returns the world-space position of this composite shape.
+     *
+     * @return the translation component of this shape's transform
+     */
+    public Point3D getLocation() {
+        return transform.getTranslation();
+    }
+
+    /**
+     * Returns the axis-aligned bounding box encompassing all sub-shapes.
+     *
+     * <p>The bounding box is computed by aggregating the bounds of all visible
+     * sub-shapes, then transforming the result by this composite's own transform.</p>
+     *
+     * <p><b>Caching:</b> The bounding box is recomputed whenever
+     * {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).
+     * For nested composites, the bounds include their local transform offset.</p>
+     *
+     * @return the axis-aligned bounding box in this composite's local coordinates
+     */
+    @Override
+    public Box getBoundingBox() {
+        if (cachedBoundingBox == null || cacheNeedsRebuild) {
+            if (subShapesRegistry.isEmpty()) {
+                return super.getBoundingBox();
+            }
+
+            double minX = Double.MAX_VALUE;
+            double maxX = -Double.MAX_VALUE;
+            double minY = Double.MAX_VALUE;
+            double maxY = -Double.MAX_VALUE;
+            double minZ = Double.MAX_VALUE;
+            double maxZ = -Double.MAX_VALUE;
+
+            for (final SubShape subShape : subShapesRegistry) {
+                if (!subShape.isVisible()) {
+                    continue;
+                }
+
+                final AbstractShape shape = subShape.getShape();
+                final Box shapeBounds = shape.getBoundingBox();
+
+                // Get bounds and apply sub-shape's transform if it's a composite
+                Point3D shapeMin = new Point3D(shapeBounds.getMinX(), shapeBounds.getMinY(), shapeBounds.getMinZ());
+                Point3D shapeMax = new Point3D(shapeBounds.getMaxX(), shapeBounds.getMaxY(), shapeBounds.getMaxZ());
+                if (shape instanceof AbstractCompositeShape) {
+                    final Transform subTransform = ((AbstractCompositeShape) shape).getTransform();
+                    final Point3D subTranslation = subTransform.getTranslation();
+                    shapeMin.add(subTranslation);
+                    shapeMax.add(subTranslation);
+                }
+
+                minX = Math.min(minX, shapeMin.x);
+                maxX = Math.max(maxX, shapeMax.x);
+                minY = Math.min(minY, shapeMin.y);
+                maxY = Math.max(maxY, shapeMax.y);
+                minZ = Math.min(minZ, shapeMin.z);
+                maxZ = Math.max(maxZ, shapeMax.z);
+            }
+
+            if (minX == Double.MAX_VALUE) {
+                // No visible shapes
+                return super.getBoundingBox();
+            }
+
+            cachedBoundingBox = new Box(
+                    new Point3D(minX, minY, minZ),
+                    new Point3D(maxX, maxY, maxZ)
+            );
+        }
+        return cachedBoundingBox;
+    }
+
+    /**
+     * Returns the sub-shapes registry (source of truth for all sub-shapes).
+     *
+     * <p>This is the authoritative list of all sub-shapes including hidden ones.
+     * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).</p>
+     *
+     * @return the registry list of all sub-shapes with their group and visibility metadata
+     * @see #cachedRenderList the frame-optimized cache derived from this registry
+     */
+    public List<SubShape> getSubShapesRegistry() {
+        return subShapesRegistry;
+    }
+
+    /**
+     * Extracts all SolidPolygon instances from this composite shape.
+     *
+     * <p>Recursively traverses the shape hierarchy and collects all
+     * SolidPolygon instances. Used for CSG operations where polygons
+     * are needed directly without conversion.</p>
+     *
+     * @return list of SolidPolygon instances from this shape hierarchy
+     */
+    public List<SolidPolygon> extractSolidPolygons() {
+        final List<SolidPolygon> result = new ArrayList<>();
+        for (final SubShape subShape : subShapesRegistry) {
+            final AbstractShape shape = subShape.getShape();
+            if (shape instanceof SolidPolygon) {
+                result.add((SolidPolygon) shape);
+            } else if (shape instanceof AbstractCompositeShape) {
+                result.addAll(((AbstractCompositeShape) shape).extractSolidPolygons());
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Returns the view-space tracker that monitors the distance
+     * and angle between the camera and this shape for level-of-detail adjustments.
+     *
+     * @return the view-space tracker for this shape
+     */
+    public ViewSpaceTracker getViewSpaceTracker() {
+        return viewSpaceTracker;
+    }
+
+    /**
+     * Hides all sub-shapes belonging to the specified group.
+     * Hidden shapes are not rendered but remain in the collection.
+     *
+     * @param groupIdentifier the group to hide
+     * @see #showGroup(String)
+     * @see #removeGroup(String)
+     */
+    public void hideGroup(final String groupIdentifier) {
+        for (final SubShape subShape : subShapesRegistry) {
+            if (subShape.matchesGroup(groupIdentifier)) {
+                subShape.setVisible(false);
+                cacheNeedsRebuild = true;
+            }
+        }
+    }
+
+    /**
+     * Permanently removes all sub-shapes belonging to the specified group.
+     *
+     * @param groupIdentifier the group to remove
+     * @see #hideGroup(String)
+     */
+    public void removeGroup(final String groupIdentifier) {
+        final java.util.Iterator<SubShape> iterator = subShapesRegistry
+                .iterator();
+
+        while (iterator.hasNext()) {
+            final SubShape subShape = iterator.next();
+            if (subShape.matchesGroup(groupIdentifier)) {
+                iterator.remove();
+                cacheNeedsRebuild = true;
+            }
+        }
+    }
+
+    /**
+     * Returns all sub-shapes belonging to the specified group.
+     *
+     * @param groupIdentifier the group identifier to match
+     * @return list of matching sub-shapes
+     */
+    public List<SubShape> getGroup(final String groupIdentifier) {
+        final List<SubShape> result = new ArrayList<>();
+        for (int i = 0; i < subShapesRegistry.size(); i++) {
+            final SubShape subShape = subShapesRegistry.get(i);
+            if (subShape.matchesGroup(groupIdentifier))
+                result.add(subShape);
+        }
+        return result;
+    }
+
+    /**
+     * Rebuilds the cached render list if shapes were added, removed, or
+     * visibility changed since the last rebuild.
+     *
+     * @param context the rendering context for logging
+     */
+    private void rebuildRenderListIfNeeded(final RenderingContext context) {
+        if (cacheNeedsRebuild)
+            rebuildRenderList(context);
+    }
+
+    /**
+     * Paint solid elements of this composite shape into given color.
+     *
+     * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
+     *
+     * @param color the color to apply to all solid sub-shapes
+     */
+    public void setColor(final Color color) {
+        for (final SubShape subShape : getSubShapesRegistry()) {
+            final AbstractShape shape = subShape.getShape();
+
+            if (shape instanceof SolidPolygon) {
+                ((SolidPolygon) shape).setColor(color);
+            } else if (shape instanceof Line) {
+                ((Line) shape).color = color;
+            } else if (shape instanceof AbstractCompositeShape) {
+                ((AbstractCompositeShape) shape).setColor(color);
+            }
+        }
+    }
+
+    /**
+     * Assigns a group identifier to all sub-shapes that currently have no group.
+     *
+     * @param groupIdentifier the group to assign to ungrouped shapes
+     */
+    public void setGroupForUngrouped(final String groupIdentifier) {
+        for (final SubShape subShape : subShapesRegistry)
+            if (subShape.isUngrouped())
+                subShape.setGroup(groupIdentifier);
+    }
+
+    @Override
+    public void setMouseInteractionController(
+            final MouseInteractionController mouseInteractionController) {
+        super.setMouseInteractionController(mouseInteractionController);
+
+        for (final SubShape subShape : subShapesRegistry)
+            subShape.getShape().setMouseInteractionController(
+                    mouseInteractionController);
+
+        cacheNeedsRebuild = true;
+    }
+
+    /**
+     * Marks this composite as the root scene container.
+     *
+     * <p>Called by {@code ShapeCollection} to configure its root composite.</p>
+     *
+     * @param isRoot {@code true} if this is the root composite, {@code false} otherwise
+     */
+    public void setRootComposite(final boolean isRoot) {
+        this.isRootComposite = isRoot;
+    }
+
+    /**
+     * Returns this composite's transform (position and orientation).
+     *
+     * @return the transform object
+     */
+    public Transform getTransform() {
+        return transform;
+    }
+
+    /**
+     * Sets the transform for this composite shape.
+     *
+     * @param transform the new transform
+     * @return this composite shape (for chaining)
+     */
+    public AbstractCompositeShape setTransform(final Transform transform) {
+        this.transform = transform;
+        return this;
+    }
+
+/**
+     * Sets the cache rebuild flag on this composite and all nested composites recursively.
+     *
+     * <p>Used by {@code ShapeCollection} to trigger a render-list rebuild when
+     * clearing the scene or for other advanced use cases.</p>
+     *
+     * @param needsRebuild {@code true} to force cache rebuild on next frame
+     */
+    public void setCacheNeedsRebuild(final boolean needsRebuild) {
+        this.cacheNeedsRebuild = needsRebuild;
+        // Propagate to nested composites
+        for (final SubShape subShape : subShapesRegistry) {
+            final AbstractShape shape = subShape.getShape();
+            if (shape instanceof AbstractCompositeShape composite) {
+                composite.setCacheNeedsRebuild(needsRebuild);
+            }
+        }
+    }
+
+    /**
+     * Enables or disables shading for all SolidTriangle and SolidPolygon sub-shapes.
+     * When enabled, shapes use the global lighting manager from the rendering
+     * context to calculate flat shading based on light sources.
+     *
+     * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
+     *
+     * @param shadingEnabled {@code true} to enable shading, {@code false} to disable
+     * @return this composite shape (for chaining)
+     */
+    public AbstractCompositeShape setShadingEnabled(final boolean shadingEnabled) {
+        for (final SubShape subShape : getSubShapesRegistry()) {
+            final AbstractShape shape = subShape.getShape();
+            if (shape instanceof SolidPolygon) {
+                ((SolidPolygon) shape).setShadingEnabled(shadingEnabled);
+            } else if (shape instanceof AbstractCompositeShape) {
+                ((AbstractCompositeShape) shape).setShadingEnabled(shadingEnabled);
+            }
+        }
+        return this;
+    }
+
+    /**
+     * Enables or disables backface culling for all SolidPolygon and TexturedTriangle sub-shapes.
+     *
+     * <p>Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.</p>
+     *
+     * @param backfaceCulling {@code true} to enable backface culling, {@code false} to disable
+     * @return this composite shape (for chaining)
+     */
+    public AbstractCompositeShape setBackfaceCulling(final boolean backfaceCulling) {
+        for (final SubShape subShape : getSubShapesRegistry()) {
+            final AbstractShape shape = subShape.getShape();
+            if (shape instanceof SolidPolygon) {
+                ((SolidPolygon) shape).setBackfaceCulling(backfaceCulling);
+            } else if (shape instanceof TexturedTriangle) {
+                ((TexturedTriangle) shape).setBackfaceCulling(backfaceCulling);
+            } else if (shape instanceof AbstractCompositeShape) {
+                ((AbstractCompositeShape) shape).setBackfaceCulling(backfaceCulling);
+            }
+        }
+        return this;
+    }
+
+    /**
+     * Performs an in-place union with another composite shape.
+     *
+     * <p>This shape's SolidPolygon children are replaced with the union result.
+     * Non-SolidPolygon children from both shapes are preserved and combined.</p>
+     *
+     * <p><b>CSG Operation:</b> Union combines two shapes into one, keeping all
+     * geometry from both. Uses BSP tree algorithms for robust boolean operations.</p>
+     *
+     * <p><b>Child handling:</b></p>
+     * <ul>
+     *   <li>SolidPolygon children from both shapes → replaced with union result</li>
+     *   <li>Non-SolidPolygon children from this shape → preserved</li>
+     *   <li>Non-SolidPolygon children from other shape → added to this shape</li>
+     *   <li>Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)</li>
+     * </ul>
+     *
+     * @param other the shape to union with
+     * @see #subtract(AbstractCompositeShape)
+     * @see #intersect(AbstractCompositeShape)
+     */
+    public void union(final AbstractCompositeShape other) {
+
+        final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
+        final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
+
+        // Remove from self any polygons that are inside other (interior faces)
+        selfTree.clipTo(otherTree);
+
+        // Remove from other any polygons that are inside self (interior faces)
+        otherTree.clipTo(selfTree);
+
+        // Invert other to convert remaining polygons for the next clip step
+        otherTree.invert();
+
+        // Clip inverted other against self to remove back-facing coplanar polygons
+        otherTree.clipTo(selfTree);
+
+        // Invert back to restore correct polygon orientation
+        otherTree.invert();
+
+        // Merge other's remaining polygons into self's BSP tree
+        selfTree.addPolygons(otherTree.allPolygons());
+
+        replaceSolidPolygons(selfTree.allPolygons());
+        mergeNonPolygonChildrenFrom(other);
+    }
+
+    /**
+     * Performs an in-place subtraction with another composite shape.
+     *
+     * <p>This shape's SolidPolygon children are replaced with the difference result.
+     * The other shape acts as a "cutter" that carves out volume from this shape.</p>
+     *
+     * <p><b>CSG Operation:</b> Subtract removes the volume of the second shape
+     * from the first shape. Useful for creating holes, cavities, and cutouts.</p>
+     *
+     * <p><b>Child handling:</b></p>
+     * <ul>
+     *   <li>SolidPolygon children from this shape → replaced with difference result</li>
+     *   <li>Non-SolidPolygon children from this shape → preserved</li>
+     *   <li>All children from other shape → discarded (other is just a cutter)</li>
+     *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
+     * </ul>
+     *
+     * @param other the shape to subtract (the cutter)
+     * @see #union(AbstractCompositeShape)
+     * @see #intersect(AbstractCompositeShape)
+     */
+    public void subtract(final AbstractCompositeShape other) {
+
+        final BspTree target = new BspTree(clonePolygons(extractSolidPolygons()));
+        final BspTree cutter = new BspTree(clonePolygons(other.extractSolidPolygons()));
+
+        // Invert target: convert "inside" to "outside" and vice versa
+        // This transforms the problem from "subtract B from A" to "intersect A's complement with B's complement"
+        target.invert();
+
+        // Clip target against cutter: removes parts of target that are INSIDE the cutter
+        // Since target is inverted, this removes parts that were OUTSIDE the original target
+        target.clipTo(cutter);
+
+        // Clip cutter against (inverted) target: removes parts of cutter outside the inverted target
+        // This keeps only cutter polygons that are inside the inverted target = outside original target
+        cutter.clipTo(target);
+
+        // Invert cutter to flip its inside/outside
+        cutter.invert();
+
+        // Clip inverted cutter against target: removes coplanar back-faces
+        cutter.clipTo(target);
+
+        // Invert cutter back to correct orientation
+        cutter.invert();
+
+        // Merge cutter's polygons into target's BSP tree
+        target.addPolygons(cutter.allPolygons());
+
+        // Invert target back to restore correct inside/outside orientation
+        // Result: the carved-out volume (target minus cutter)
+        target.invert();
+
+        replaceSolidPolygons(target.allPolygons());
+    }
+
+    /**
+     * Performs an in-place intersection with another composite shape.
+     *
+     * <p>This shape's SolidPolygon children are replaced with the intersection result.
+     * Only the overlapping volume between the two shapes remains.</p>
+     *
+     * <p><b>CSG Operation:</b> Intersect keeps only the volume where both shapes
+     * overlap. Useful for creating shapes constrained by multiple boundaries.</p>
+     *
+     * <p><b>Child handling:</b></p>
+     * <ul>
+     *   <li>SolidPolygon children from this shape → replaced with intersection result</li>
+     *   <li>Non-SolidPolygon children from this shape → preserved</li>
+     *   <li>All children from other shape → discarded</li>
+     *   <li>Nested AbstractCompositeShape children → preserved unchanged</li>
+     * </ul>
+     *
+     * @param other the shape to intersect with
+     * @see #union(AbstractCompositeShape)
+     * @see #subtract(AbstractCompositeShape)
+     */
+    public void intersect(final AbstractCompositeShape other) {
+
+        final BspTree selfTree = new BspTree(clonePolygons(extractSolidPolygons()));
+        final BspTree otherTree = new BspTree(clonePolygons(other.extractSolidPolygons()));
+
+        // Invert self to convert "inside" to "outside"
+        // This transforms intersection into: keep parts that are "outside both inverted shapes"
+        selfTree.invert();
+
+        // Clip other against inverted self: keeps only parts of other that are INSIDE original self
+        // (because clipTo removes what's "outside" the BSP, and inverted self's "outside" = original self's "inside")
+        otherTree.clipTo(selfTree);
+
+        // Invert other (which now represents the intersection region)
+        otherTree.invert();
+
+        // Clip inverted self against (inverted intersection): removes parts outside the intersection
+        selfTree.clipTo(otherTree);
+
+        // Clip intersection result against inverted self: removes back-facing coplanar polygons
+        otherTree.clipTo(selfTree);
+
+        // Build final BSP tree from the clipped intersection polygons
+        selfTree.addPolygons(otherTree.allPolygons());
+
+        // Invert back to restore correct inside/outside orientation
+        selfTree.invert();
+
+        replaceSolidPolygons(selfTree.allPolygons());
+    }
+
+    /**
+     * Creates deep clones of all polygons in the list.
+     *
+     * <p>CSG operations modify polygons in-place via BSP tree operations.
+     * Cloning ensures the original polygon data is preserved.</p>
+     *
+     * @param polygons the polygons to clone
+     * @return a new list containing deep clones of all polygons
+     */
+    private List<SolidPolygon> clonePolygons(final List<SolidPolygon> polygons) {
+        final List<SolidPolygon> cloned = new ArrayList<>(polygons.size());
+        for (final SolidPolygon p : polygons) {
+            cloned.add(p.deepClone());
+        }
+        return cloned;
+    }
+
+    /**
+     * Replaces this shape's SolidPolygon children with new polygons.
+     *
+     * <p>Preserves all non-SolidPolygon children (Lines, nested composites, etc.).</p>
+     *
+     * @param newPolygons the polygons to replace with
+     */
+    private void replaceSolidPolygons(final List<SolidPolygon> newPolygons) {
+        // Remove all direct SolidPolygon children from this shape
+        final Iterator<SubShape> iterator = subShapesRegistry.iterator();
+        while (iterator.hasNext()) {
+            final SubShape subShape = iterator.next();
+            if (subShape.getShape() instanceof SolidPolygon) {
+                iterator.remove();
+            }
+        }
+
+        // Add all result polygons as new children
+        for (final SolidPolygon polygon : newPolygons) {
+            addShape(polygon);
+        }
+
+        cacheNeedsRebuild = true;
+    }
+
+    /**
+     * Merges non-SolidPolygon children from another shape into this shape.
+     *
+     * <p>Copies all non-SolidPolygon children (Lines, nested composites, etc.)
+     * from the other shape, preserving their group identifiers.</p>
+     *
+     * @param other the shape to merge non-polygon children from
+     */
+    private void mergeNonPolygonChildrenFrom(final AbstractCompositeShape other) {
+        if (other == null) {
+            return;
+        }
+
+        for (final SubShape otherSubShape : other.subShapesRegistry) {
+            final AbstractShape otherShape = otherSubShape.getShape();
+            if (!(otherShape instanceof SolidPolygon)) {
+                addShape(otherShape, otherSubShape.getGroupIdentifier());
+            }
+        }
+
+        cacheNeedsRebuild = true;
+    }
+
+    /**
+     * Makes all sub-shapes belonging to the specified group visible.
+     *
+     * @param groupIdentifier the group to show
+     * @see #hideGroup(String)
+     */
+    public void showGroup(final String groupIdentifier) {
+        for (int i = 0; i < subShapesRegistry.size(); i++) {
+            final SubShape subShape = subShapesRegistry.get(i);
+            if (subShape.matchesGroup(groupIdentifier)) {
+                subShape.setVisible(true);
+                cacheNeedsRebuild = true;
+            }
+        }
+    }
+
+    /**
+     * Rebuilds the cached render list from the shape registry:
+     * textured triangles pass through as-is (perspective-correct scanline
+     * rendering needs no tessellation), N-vertex solid polygons are
+     * fan-triangulated, everything else passes through.
+     * Logs the operation to the debug log buffer if available.
+     *
+     * @param context the rendering context for logging, may be {@code null}
+     */
+    private void rebuildRenderList(final RenderingContext context) {
+        cacheNeedsRebuild = false;
+
+        final List<AbstractShape> result = new ArrayList<>();
+        int texturedPolygonCount = 0;
+        int solidPolygonCount = 0;
+        int triangulatedPolygonCount = 0;
+        int otherShapeCount = 0;
+
+        for (int i = 0; i < subShapesRegistry.size(); i++) {
+            final SubShape subShape = subShapesRegistry.get(i);
+            if (!subShape.isVisible())
+                continue;
+
+            final AbstractShape shape = subShape.getShape();
+
+            if (shape instanceof TexturedTriangle) {
+                result.add(shape);
+                texturedPolygonCount++;
+            } else if (shape instanceof SolidPolygon polygon) {
+                final int vertexCount = polygon.getVertexCount();
+
+                if (vertexCount == 3) {
+                    result.add(polygon);
+                    solidPolygonCount++;
+                } else {
+                    triangulateSolidPolygon(polygon, result);
+                    triangulatedPolygonCount++;
+                }
+            } else {
+                result.add(shape);
+                otherShapeCount++;
+            }
+        }
+
+        cachedRenderList = postprocessRenderList(result);
+        renderListVersion++;
+        globalRenderListVersion.incrementAndGet();
+
+        if (context != null && context.debugLogBuffer != null) {
+            context.debugLogBuffer.log("rebuildRenderList: " + getClass().getSimpleName()
+                    + " texturedPolygons=" + texturedPolygonCount
+                    + " solidPolygons=" + solidPolygonCount
+                    + " triangulatedPolygons=" + triangulatedPolygonCount
+                    + " otherShapes=" + otherShapeCount);
+        }
+    }
+
+    /**
+     * Returns the global render list version: incremented every time ANY
+     * composite's render list is rebuilt. Used by derived structures
+     * (BSP trees, GI scene snapshots) to detect that they must rebuild.
+     *
+     * @return monotonically increasing global version
+     */
+    public static int getGlobalRenderListVersion() {
+        return globalRenderListVersion.get();
+    }
+
+    /**
+     * Collects the triangles of this composite's current render list,
+     * recursing into nested composites. These are the exact objects that
+     * get transformed and rendered: triangulated render-list polygons, or
+     * lightmapped wrappers for
+     * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}.
+     *
+     * <p>Render lists are built lazily during transform; composites that
+     * have not been transformed yet (or are frustum-culled) contribute
+     * nothing. Vertices are in each composite's local space — callers
+     * combining several composites should require identity transforms.</p>
+     *
+     * @param out list receiving the triangles
+     */
+    public void collectRenderTriangles(final List<AbstractCoordinateShape> out) {
+        if (cachedRenderList == null)
+            return;
+        for (final AbstractShape shape : cachedRenderList) {
+            if (shape instanceof SolidPolygon
+                    || shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle)
+                out.add((AbstractCoordinateShape) shape);
+            else if (shape instanceof AbstractCompositeShape)
+                ((AbstractCompositeShape) shape).collectRenderTriangles(out);
+        }
+    }
+
+    /**
+     * Hook: post-processes the freshly rebuilt render list before it becomes
+     * the rendering cache. The default implementation returns the list
+     * unchanged. Subclasses may replace the list — e.g.
+     * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}
+     * wraps its polygons into lightmapped triangles here.
+     *
+     * @param renderList the render list built from the shape registry
+     * @return the render list to cache and render
+     */
+    protected List<AbstractShape> postprocessRenderList(final List<AbstractShape> renderList) {
+        return renderList;
+    }
+
+    /**
+     * Triangulates a convex solid polygon using fan triangulation.
+     *
+     * <p>Fan triangulation creates N-2 triangles from an N-vertex polygon by using
+     * vertex 0 as the anchor and connecting it to each adjacent pair of vertices.</p>
+     *
+     * <p>Properties (color, shading, backface culling, mouse interaction) are
+     * propagated to each resulting triangle to ensure consistent behavior.</p>
+     *
+     * @param polygon the polygon to triangulate (must have at least 4 vertices)
+     * @param result  the list to add the resulting triangles to
+     */
+    private void triangulateSolidPolygon(final SolidPolygon polygon,
+                                         final List<AbstractShape> result) {
+
+        final Color color = polygon.getColor();
+        final boolean shadingEnabled = polygon.isShadingEnabled();
+        final boolean backfaceCulling = polygon.isBackfaceCullingEnabled();
+        final MouseInteractionController mouseController = polygon.mouseInteractionController;
+
+        final List<Vertex> vertices = polygon.vertices;
+        final Vertex v0 = vertices.get(0);
+
+        for (int i = 1; i < vertices.size() - 1; i++) {
+            final Vertex v1 = vertices.get(i);
+            final Vertex v2 = vertices.get(i + 1);
+
+            final SolidPolygon triangle = new SolidPolygon(
+                    v0.coordinate, v1.coordinate, v2.coordinate, color);
+
+            triangle.setShadingEnabled(shadingEnabled);
+            triangle.setBackfaceCulling(backfaceCulling);
+            triangle.setMouseInteractionController(mouseController);
+
+            result.add(triangle);
+        }
+    }
+
+    @Override
+    public void transform(final TransformStack transformPipe,
+                          final RenderAggregator aggregator, final RenderingContext context) {
+
+        // Add the current composite shape transform to the end of the transform
+        // pipeline.
+        transformPipe.addTransform(transform);
+
+        // FRUSTUM CULLING: Check if this composite's bounds are visible
+        // Root composite skips this check (its bounds are always the full scene)
+        // Non-root composites check their aggregated bounds against the frustum
+        if (context.frustum != null && !isRootComposite) {
+            // Count this composite for culling statistics (before frustum test)
+            if (context.cullingStatistics != null) {
+                context.cullingStatistics.totalComposites.incrementAndGet();
+            }
+
+            final Box localBounds = getBoundingBox();
+
+            // Transform all 8 corners of the bounding box to view space
+            final double minX = localBounds.getMinX();
+            final double maxX = localBounds.getMaxX();
+            final double minY = localBounds.getMinY();
+            final double maxY = localBounds.getMaxY();
+            final double minZ = localBounds.getMinZ();
+            final double maxZ = localBounds.getMaxZ();
+
+            final double[] xs = {minX, maxX};
+            final double[] ys = {minY, maxY};
+            final double[] zs = {minZ, maxZ};
+
+            double viewMinX = Double.MAX_VALUE;
+            double viewMaxX = -Double.MAX_VALUE;
+            double viewMinY = Double.MAX_VALUE;
+            double viewMaxY = -Double.MAX_VALUE;
+            double viewMinZ = Double.MAX_VALUE;
+            double viewMaxZ = -Double.MAX_VALUE;
+
+            for (int i = 0; i < 8; i++) {
+                final double x = xs[(i & 1)];
+                final double y = ys[(i >> 1) & 1];
+                final double z = zs[(i >> 2) & 1];
+
+                final Point3D corner = transformPointToViewSpace(x, y, z, transformPipe);
+
+                viewMinX = Math.min(viewMinX, corner.x);
+                viewMaxX = Math.max(viewMaxX, corner.x);
+                viewMinY = Math.min(viewMinY, corner.y);
+                viewMaxY = Math.max(viewMaxY, corner.y);
+                viewMinZ = Math.min(viewMinZ, corner.z);
+                viewMaxZ = Math.max(viewMaxZ, corner.z);
+            }
+
+            final Box viewSpaceBounds = new Box(
+                    new Point3D(viewMinX, viewMinY, viewMinZ),
+                    new Point3D(viewMaxX, viewMaxY, viewMaxZ)
+            );
+
+            final Frustum frustum = context.frustum;
+            final boolean visible = frustum.intersectsAABB(viewSpaceBounds);
+
+            if (!visible) {
+                // Entire composite outside frustum - skip processing all children
+                if (context.cullingStatistics != null) {
+                    context.cullingStatistics.culledComposites.incrementAndGet();
+                }
+                transformPipe.dropTransform();
+                return;
+            }
+        }
+
+        viewSpaceTracker.analyze(transformPipe, context);
+
+        beforeTransformHook(transformPipe, context);
+
+        rebuildRenderListIfNeeded(context);
+
+        // transform rendered subshapes
+        if (shouldForkTransform(context)) {
+            transformChildrenParallel(transformPipe, aggregator, context);
+        } else {
+            transformChildrenSerial(transformPipe, aggregator, context);
+        }
+
+        transformPipe.dropTransform();
+    }
+
+    /**
+     * Minimum number of children before the parallel fork is considered at
+     * all. A single child cannot be split; splitting happens in the child.
+     */
+    private static final int PARALLEL_TRANSFORM_MIN_SHAPES = 2;
+
+    /**
+     * Minimum total subtree weight (leaf primitives below this composite)
+     * before forking its transform into parallel chunks. Below this, the
+     * serial walk is cheaper than the fork overhead. Deliberately above
+     * one sphere's generated triangle count (~960 at 16 segments):
+     * measured 2026-09-04, forking those pays task overhead per chunk for
+     * negligible serial work (35k chunk tasks on a 3000-sphere scene were
+     * SLOWER than serial).
+     */
+    private static final int PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT = 2048;
+
+    /**
+     * Minimum weight per parallel chunk task. Keeps chunk granularity
+     * coarse enough that task dispatch overhead stays negligible.
+     */
+    private static final int PARALLEL_TASK_MIN_WEIGHT = 512;
+
+    /**
+     * Cached subtree weight from {@link #getTransformWeight}, valid for
+     * {@link #subtreeWeightCycle} only.
+     */
+    private int cachedSubtreeWeight;
+
+    /**
+     * Transform cycle id the cached subtree weight was computed on.
+     * Keyed on the globally unique cycle id, not the per-context frame
+     * number, so alternating between rendering contexts cannot produce
+     * stale cache hits.
+     */
+    private long subtreeWeightCycle = -1;
+
+    /**
+     * Transform cycle id the cached subtree weight was last RECOMPUTED on.
+     * Separate from {@link #subtreeWeightCycle} (last read): the refresh
+     * gate measures the age of the computation, not of the last access.
+     */
+    private long subtreeWeightComputeCycle = -1;
+
+    /**
+     * {@link #renderListVersion} at the last weight recomputation.
+     */
+    private int weightListVersion = -1;
+
+    /**
+     * Bumped whenever ANY composite rebuilds its render list. Lets the
+     * weight shortcut react to structural changes anywhere in the tree
+     * within one cycle, at O(1) per node per cycle — scanning direct
+     * children's versions instead costs O(leaves) per frame because leaf
+     * lists dominate (measured 2026-09-04: +3-4 ms/frame on a 400-sphere
+     * scene).
+     */
+    private static final AtomicInteger globalRenderListVersion = new AtomicInteger();
+
+    /**
+     * {@link #globalRenderListVersion} value seen at the last weight
+     * recomputation.
+     */
+    private int weightGlobalVersion = -1;
+
+    /**
+     * Bumped every time {@link #cachedRenderList} is rebuilt. Gates weight
+     * recomputation: while the list is unchanged, the cached weight is
+     * reused without re-walking the subtree.
+     */
+    private int renderListVersion;
+
+    /**
+     * Full subtree weight re-walks are O(total leaves below this node),
+     * which costs real milliseconds on big meshes. With an unchanged render
+     * list the cached weight is refreshed at most every this many cycles;
+     * structural changes (rebuilds) recompute immediately.
+     */
+    private static final long WEIGHT_REFRESH_CYCLES = 16;
+
+    /**
+     * Total transform weight of this composite: the sum of its children's
+     * weights, i.e. roughly the number of leaf primitives below it.
+     * Computed lazily; recomputed only when this node's render list was
+     * rebuilt or the cache is older than {@link #WEIGHT_REFRESH_CYCLES}
+     * cycles (children's internal rebuilds are picked up by the periodic
+     * refresh). Used solely for parallel fork load balancing, never for
+     * correctness, so brief staleness is harmless.
+     *
+     * <p>Thread safety: a composite's fork decision runs on exactly one
+     * thread per cycle. Chunk-thread reads are cycle-stamped cache hits
+     * published through the executor's happens-before edge.</p>
+     *
+     * @param renderingContext the rendering context (cycle identity)
+     * @return subtree transform weight, at least 1
+     */
+    @Override
+    public int getTransformWeight(final RenderingContext renderingContext) {
+        final long cycle = renderingContext.transformCycleId;
+        if (subtreeWeightCycle == cycle) {
+            return cachedSubtreeWeight;
+        }
+        final int globalVersion = globalRenderListVersion.get();
+        if (weightListVersion == renderListVersion
+                && weightGlobalVersion == globalVersion
+                && cycle - subtreeWeightComputeCycle < WEIGHT_REFRESH_CYCLES) {
+            // Nothing rebuilt anywhere and computation fresh: keep the
+            // value, just re-stamp the read. O(1) per node per cycle.
+            subtreeWeightCycle = cycle;
+            return cachedSubtreeWeight;
+        }
+        int weight = 0;
+        for (final AbstractShape child : cachedRenderList) {
+            weight += child.getTransformWeight(renderingContext);
+        }
+        cachedSubtreeWeight = Math.max(1, weight);
+        subtreeWeightCycle = cycle;
+        subtreeWeightComputeCycle = cycle;
+        weightListVersion = renderListVersion;
+        weightGlobalVersion = globalVersion;
+        return cachedSubtreeWeight;
+    }
+
+    /**
+     * Decides whether this composite forks its children's transform into
+     * parallel chunks: enough children to split, and enough TOTAL weight
+     * below it to amortize the fork overhead. Weight (not local child
+     * count) is what matters: a deep narrow tree with two heavy children
+     * forks just like a flat mesh with thousands of leaves.
+     *
+     * @param context the rendering context (provides the coordinator)
+     * @return true when the parallel fork should be taken
+     */
+    private boolean shouldForkTransform(final RenderingContext context) {
+        if (context.transformCoordinator == null) {
+            return false;
+        }
+        if (cachedRenderList.size() < PARALLEL_TRANSFORM_MIN_SHAPES) {
+            return false;
+        }
+        return getTransformWeight(context) >= PARALLEL_TRANSFORM_MIN_SUBTREE_WEIGHT;
+    }
+
+    /**
+     * Target number of task chunks per available processor core.
+     * More tasks than cores gives the pool load balancing across
+     * shapes with uneven transform cost.
+     */
+    private static final int PARALLEL_TASKS_PER_CORE = 4;
+
+    /**
+     * Transforms all children serially on the calling thread.
+     *
+     * @param transformPipe the transform stack (includes this composite's transform)
+     * @param aggregator    the aggregator to queue visible shapes into
+     * @param context       the rendering context
+     */
+    private void transformChildrenSerial(final TransformStack transformPipe,
+                                         final RenderAggregator aggregator,
+                                         final RenderingContext context) {
+        for (final AbstractShape shape : cachedRenderList) {
+            shape.transform(transformPipe, aggregator, context);
+        }
+    }
+
+    /**
+     * Forks the children's transform into parallel chunk tasks on the
+     * frame's {@link ParallelTransformCoordinator} and returns immediately
+     * WITHOUT waiting for them.
+     *
+     * <p>Works at any nesting level: a heavy composite reached inside a
+     * chunk task forks its own children into the same coordinator. This is
+     * deadlock-safe because chunk tasks never block on other tasks; only
+     * the orchestrating render thread waits (in the coordinator's drain).</p>
+     *
+     * <p>Stack snapshotting: this composite's transform is dropped from
+     * {@code transformPipe} right after this method returns, long before
+     * the chunk tasks run, so the pipe is copied HERE on the forking
+     * thread. Each task then copies the snapshot for its own working stack.
+     * The snapshot is never mutated after publication, so concurrent
+     * copying by chunk tasks is safe.</p>
+     *
+     * <p>Thread-safety notes: sibling composites are exclusively owned by
+     * one chunk, so their per-instance caches (render list, bounding
+     * boxes, own transform's cached matrix) never race. The vertex
+     * frameNumber cache is a benign race: every thread writes the same
+     * value.</p>
+     *
+     * @param transformPipe the transform stack (includes this composite's transform)
+     * @param aggregator    unused in the parallel path: per-task aggregators
+     *                      are merged by the coordinator's drain
+     * @param context       the rendering context (provides the coordinator)
+     */
+    private void transformChildrenParallel(final TransformStack transformPipe,
+                                           final RenderAggregator aggregator,
+                                           final RenderingContext context) {
+        // Snapshot the render list reference. With the pipelined render
+        // loop, the NEXT pass's tree walk can already be running while
+        // this pass's chunk tasks are still queued (the drain happens in
+        // the async continuation, not before the next walk). That walk
+        // may rebuild this composite's render list, REASSIGNING
+        // cachedRenderList to a new list of a different size. The chunk
+        // ranges below are computed against this list instance, so the
+        // chunk tasks must index this same instance — re-reading the
+        // field inside the lambda raced with the rebuild and threw
+        // IndexOutOfBoundsException. (The old list stays alive and valid
+        // for this pass; each pass transforms into its own vertex slot.)
+        final List<AbstractShape> renderList = cachedRenderList;
+        final int size = renderList.size();
+        final int totalWeight = getTransformWeight(context);
+        final int processors = Runtime.getRuntime().availableProcessors();
+        final int targetTasks = processors * PARALLEL_TASKS_PER_CORE;
+        final int taskWeight = Math.max(PARALLEL_TASK_MIN_WEIGHT,
+                (totalWeight + targetTasks - 1) / targetTasks);
+
+        // Pass 1: count chunks, cutting by ACCUMULATED WEIGHT so that a
+        // node with few but heavy children (e.g. two 25k-triangle halves
+        // of a fractal) still splits into multiple tasks
+        int taskCount = 0;
+        int accumulated = 0;
+        for (int i = 0; i < size; i++) {
+            accumulated += renderList.get(i).getTransformWeight(context);
+            if (accumulated >= taskWeight) {
+                taskCount++;
+                accumulated = 0;
+            }
+        }
+        if (accumulated > 0) {
+            taskCount++;
+        }
+
+        if (taskCount < 2) {
+            transformChildrenSerial(transformPipe, aggregator, context);
+            return;
+        }
+
+        final ParallelTransformCoordinator coordinator = context.transformCoordinator;
+        if (!coordinator.tryReserveTasks(taskCount)) {
+            // Frame-wide task budget exhausted: transform inline
+            transformChildrenSerial(transformPipe, aggregator, context);
+            return;
+        }
+
+        final TransformStack snapshot = new TransformStack(transformPipe);
+
+        // Pass 2: submit chunks (weights are frame-cached, cheap re-walk)
+        int from = 0;
+        accumulated = 0;
+        for (int i = 0; i < size; i++) {
+            accumulated += renderList.get(i).getTransformWeight(context);
+            if (accumulated >= taskWeight || i == size - 1) {
+                final int chunkFrom = from;
+                final int chunkTo = i + 1;
+                coordinator.submit(() -> {
+                    // Pooled scratch: the stack (9.6 KB of arrays) and
+                    // the chunk aggregator (queue keeps its capacity
+                    // across frames) come from the coordinator's static
+                    // pools — previously each chunk allocated both on
+                    // every frame.
+                    final TransformStack taskStack =
+                            coordinator.borrowStack(snapshot);
+                    try {
+                        final RenderAggregator taskAggregator =
+                                coordinator.borrowAggregator();
+                        for (int c = chunkFrom; c < chunkTo; c++) {
+                            renderList.get(c).transform(taskStack, taskAggregator, context);
+                        }
+                        return taskAggregator;
+                    } finally {
+                        coordinator.returnStack(taskStack);
+                    }
+                });
+                from = i + 1;
+                accumulated = 0;
+            }
+        }
+    }
+
+    /**
+     * Transforms a point to view space using the current transform stack.
+     * Helper method for frustum culling that transforms bounding box corners.
+     *
+     * @param x             the X coordinate in local space
+     * @param y             the Y coordinate in local space
+     * @param z             the Z coordinate in local space
+     * @param transformPipe the current transform stack
+     * @return the transformed point in view space
+     */
+    private Point3D transformPointToViewSpace(final double x, final double y, final double z,
+                                              final TransformStack transformPipe) {
+        final Point3D input = new Point3D(x, y, z);
+        final Point3D result = new Point3D();
+        transformPipe.transform(input, result);
+        return result;
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/SubShape.java
new file mode 100644 (file)
index 0000000..19ed0d0
--- /dev/null
@@ -0,0 +1,128 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
+
+import java.util.Objects;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape;
+
+/**
+ * Wrapper around an {@link AbstractShape} within an {@link AbstractCompositeShape},
+ * adding group membership and visibility control.
+ *
+ * <p>Sub-shapes can be organized into named groups so they can be shown, hidden,
+ * or removed together. This is useful for toggling parts of a composite shape,
+ * such as showing/hiding labels, highlights, or selection borders.</p>
+ *
+ * @see AbstractCompositeShape#addShape(AbstractShape, String)
+ * @see AbstractCompositeShape#hideGroup(String)
+ * @see AbstractCompositeShape#showGroup(String)
+ */
+public class SubShape {
+
+    /**
+     * The wrapped shape that belongs to the parent composite shape.
+     * This is the actual renderable geometry (line, polygon, etc.).
+     */
+    private final AbstractShape shape;
+
+    /**
+     * Whether this sub-shape should be rendered.
+     * Hidden shapes remain in the composite but are excluded from rendering.
+     */
+    private boolean visible = true;
+
+    /**
+     * The group identifier for batch visibility operations.
+     * {@code null} indicates this shape is not part of any named group.
+     */
+    private String groupIdentifier;
+
+    /**
+     * Creates a sub-shape wrapper around the given shape with default visibility (visible).
+     *
+     * @param shape the shape to wrap
+     */
+    public SubShape(final AbstractShape shape) {
+        this(shape, null, true);
+    }
+
+    /**
+     * Creates a sub-shape with all properties specified.
+     *
+     * @param shape           the shape to wrap
+     * @param groupIdentifier the group identifier, or {@code null} for ungrouped
+     * @param visible         whether the shape is initially visible
+     */
+    public SubShape(final AbstractShape shape, final String groupIdentifier, final boolean visible) {
+        this.shape = shape;
+        this.groupIdentifier = groupIdentifier;
+        this.visible = visible;
+    }
+
+    /**
+     * Returns {@code true} if this sub-shape has no group assigned.
+     *
+     * @return {@code true} if ungrouped
+     */
+    public boolean isUngrouped() {
+        return groupIdentifier == null;
+    }
+
+    /**
+     * Checks whether this sub-shape belongs to the specified group.
+     *
+     * @param groupIdentifier the group identifier to match against, or {@code null} to match ungrouped shapes
+     * @return {@code true} if this sub-shape belongs to the specified group
+     */
+    public boolean matchesGroup(final String groupIdentifier) {
+        return Objects.equals(this.groupIdentifier, groupIdentifier);
+    }
+
+    /**
+     * Returns the group identifier for this sub-shape.
+     *
+     * @return the group identifier, or {@code null} if this shape is ungrouped
+     */
+    public String getGroupIdentifier() {
+        return groupIdentifier;
+    }
+
+    /**
+     * Assigns this sub-shape to a group.
+     *
+     * @param groupIdentifier the group identifier, or {@code null} to make it ungrouped
+     */
+    public void setGroup(final String groupIdentifier) {
+        this.groupIdentifier = groupIdentifier;
+    }
+
+    /**
+     * Returns the wrapped shape.
+     *
+     * @return the underlying shape
+     */
+    public AbstractShape getShape() {
+        return shape;
+    }
+
+    /**
+     * Returns whether this sub-shape is currently visible and will be rendered.
+     *
+     * @return {@code true} if visible
+     */
+    public boolean isVisible() {
+        return visible;
+    }
+
+    /**
+     * Sets the visibility of this sub-shape.
+     *
+     * @param visible {@code true} to make the shape visible, {@code false} to hide it
+     */
+    public void setVisible(boolean visible) {
+        this.visible = visible;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/package-info.java
new file mode 100644 (file)
index 0000000..a35e03c
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Base class and utilities for composite shapes.
+ *
+ * <p>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape}
+ * is the foundation for building complex 3D objects by grouping primitives.</p>
+ *
+ * <p>Features:</p>
+ * <ul>
+ *   <li>Position and rotation in 3D space</li>
+ *   <li>Named groups for selective visibility</li>
+ *   <li>Automatic sub-shape management</li>
+ *   <li>Integration with lighting and slicing</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.SubShape
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/package-info.java
new file mode 100644 (file)
index 0000000..d75f5eb
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Composite shapes that group multiple primitives into compound 3D objects.
+ *
+ * <p>Composite shapes allow building complex objects from simpler primitives.
+ * They support grouping, visibility toggling, and hierarchical transformations.</p>
+ *
+ * <p>Subpackages:</p>
+ * <ul>
+ *   <li>{@code base} - Base class for all composite shapes</li>
+ *   <li>{@code solid} - Solid objects (cubes, spheres, cylinders)</li>
+ *   <li>{@code wireframe} - Wireframe objects (boxes, grids, spheres)</li>
+ *   <li>{@code textcanvas} - 3D text rendering canvas</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java
new file mode 100644 (file)
index 0000000..072a8b3
--- /dev/null
@@ -0,0 +1,324 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A 3D arrow shape composed of a cylindrical body and a conical tip.
+ *
+ * <p>The arrow points from a start point to an end point, with the tip
+ * located at the end point. The arrow's appearance (size, color, transparency)
+ * can be customized through the constructor parameters.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a red arrow pointing from origin to (100, -50, 200)
+ * SolidPolygonArrow arrow = new SolidPolygonArrow(
+ *     new Point3D(0, 0, 0),      // start point
+ *     new Point3D(100, -50, 200), // end point
+ *     8,                         // body radius
+ *     20,                        // tip radius
+ *     40,                        // tip length
+ *     16,                        // segments
+ *     Color.RED                  // color
+ * );
+ * shapeCollection.addShape(arrow);
+ *
+ * // Create a semi-transparent blue arrow
+ * SolidPolygonArrow seeThroughArrow = new SolidPolygonArrow(
+ *     new Point3D(0, 100, 0),
+ *     new Point3D(0, -100, 0),
+ *     10, 25, 50, 12,
+ *     new Color(0, 0, 255, 128)  // blue with 50% transparency
+ * );
+ * }</pre>
+ *
+ * @see SolidPolygonCone
+ * @see SolidPolygonCylinder
+ */
+public class SolidPolygonArrow extends AbstractCompositeShape {
+
+    /**
+     *
+     * Number of segments for arrow smoothness.
+     */
+    private static final int SEGMENTS = 12;
+
+    /**
+     * Arrow tip radius as a fraction of body radius (2.5x).
+     */
+    private static final double TIP_RADIUS_FACTOR = 2.5;
+
+    /**
+     * Arrow tip length as a fraction of body radius (5.0x).
+     */
+    private static final double TIP_LENGTH_FACTOR = 5.0;
+
+    /**
+     * Constructs a 3D arrow pointing from start to end with sensible defaults.
+     *
+     * <p>This simplified constructor automatically calculates the tip radius as
+     * 2.5 times the body radius, the tip length as 5 times the body radius, and
+     * uses 12 segments for smoothness. For custom tip dimensions or segment count,
+     * use the full constructor.</p>
+     *
+     * @param startPoint the origin point of the arrow (where the body starts)
+     * @param endPoint   the destination point of the arrow (where the tip points to)
+     * @param bodyRadius the radius of the cylindrical body; tip dimensions are
+     *                   calculated automatically from this value
+     * @param color      the fill color (RGBA; alpha controls transparency)
+     */
+    public SolidPolygonArrow(final Point3D startPoint, final Point3D endPoint,
+                             final double bodyRadius, final Color color) {
+        super();
+
+        // Calculate direction and distance
+        final double dx = endPoint.x - startPoint.x;
+        final double dy = endPoint.y - startPoint.y;
+        final double dz = endPoint.z - startPoint.z;
+        final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: start and end are the same point
+        if (distance < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector
+        final double nx = dx / distance;
+        final double ny = dy / distance;
+        final double nz = dz / distance;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default arrow points in -Y direction (apex at lower Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Calculate body length (distance minus tip)
+        final double bodyLength = Math.max(0, distance - bodyRadius * TIP_LENGTH_FACTOR);
+
+        // Build the arrow components
+        if (bodyLength > 0) {
+            addCylinderBody(startPoint, bodyRadius, bodyLength, SEGMENTS, color, rotMatrix, nx, ny, nz);
+        }
+        addConeTip(endPoint, bodyRadius * TIP_RADIUS_FACTOR, bodyRadius * TIP_LENGTH_FACTOR, SEGMENTS, color, rotMatrix, nx, ny, nz);
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The arrow by default points in the -Y direction. This method computes
+     * the rotation needed to align the arrow with the target direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+
+    /**
+     * Adds the cylindrical body of the arrow.
+     *
+     * <p>The cylinder is created with its base at the start point and extends
+     * in the direction of the arrow for the specified body length.</p>
+     *
+     * <p><b>Local coordinate system:</b> The arrow points in -Y direction in local space.
+     * Therefore, local -Y is toward the tip (front), and local +Y is toward the start (back).</p>
+     *
+     * @param startPoint the origin of the arrow body
+     * @param radius     the radius of the cylinder
+     * @param length     the length of the cylinder
+     * @param segments   the number of segments around the circumference
+     * @param color      the fill color
+     * @param rotMatrix  the rotation matrix to apply
+     * @param dirX       direction X component (for translation calculation)
+     * @param dirY       direction Y component
+     * @param dirZ       direction Z component
+     */
+    private void addCylinderBody(final Point3D startPoint, final double radius,
+                                 final double length, final int segments,
+                                 final Color color, final Matrix3x3 rotMatrix,
+                                 final double dirX, final double dirY, final double dirZ) {
+        // Cylinder center is at startPoint + (length/2) * direction
+        final double centerX = startPoint.x + (length / 2.0) * dirX;
+        final double centerY = startPoint.y + (length / 2.0) * dirY;
+        final double centerZ = startPoint.z + (length / 2.0) * dirZ;
+
+        // Generate ring vertices in local space, then rotate and translate
+        // Arrow points in -Y direction, so:
+        //   - tipSideRing is at local -Y (toward arrow tip, front of cylinder)
+        //   - startSideRing is at local +Y (toward arrow start, back of cylinder)
+        final Point3D[] tipSideRing = new Point3D[segments];
+        final Point3D[] startSideRing = new Point3D[segments];
+
+        final double halfLength = length / 2.0;
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Tip-side ring (at -halfLength in local Y = toward arrow tip)
+            final Point3D tipSideLocal = new Point3D(localX, -halfLength, localZ);
+            rotMatrix.transform(tipSideLocal, tipSideLocal);
+            tipSideLocal.x += centerX;
+            tipSideLocal.y += centerY;
+            tipSideLocal.z += centerZ;
+            tipSideRing[i] = tipSideLocal;
+
+            // Start-side ring (at +halfLength in local Y = toward arrow start)
+            final Point3D startSideLocal = new Point3D(localX, halfLength, localZ);
+            rotMatrix.transform(startSideLocal, startSideLocal);
+            startSideLocal.x += centerX;
+            startSideLocal.y += centerY;
+            startSideLocal.z += centerZ;
+            startSideRing[i] = startSideLocal;
+        }
+
+        // Create cylinder side faces (one quad per segment)
+        // Winding: tipSide[i] → startSide[i] → startSide[next] → tipSide[next]
+        // creates CCW winding when viewed from outside the cylinder
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            addShape(SolidPolygon.quad(
+                    tipSideRing[i],
+                    startSideRing[i],
+                    startSideRing[next],
+                    tipSideRing[next],
+                    color));
+        }
+
+        // Add back cap at the start point.
+        // Single N-vertex polygon that closes the loop to create segments triangles
+        // (segments+2 vertices → segments triangles via fan triangulation)
+        // The cap faces backward (away from arrow tip), opposite to arrow direction.
+        // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1]
+        // (reverse order from ring array direction)
+        final Point3D[] backCapVertices = new Point3D[segments + 2];
+        backCapVertices[0] = startPoint;
+        for (int i = 0; i < segments; i++) {
+            backCapVertices[i + 1] = startSideRing[segments - 1 - i];
+        }
+        backCapVertices[segments + 1] = startSideRing[segments - 1]; // close the loop
+        addShape(new SolidPolygon(backCapVertices, color));
+    }
+
+    /**
+     * Adds the conical tip of the arrow.
+     *
+     * <p>The cone is created with its apex at the end point (the arrow tip)
+     * and its base pointing back towards the start point.</p>
+     *
+     * <p><b>Local coordinate system:</b> In local space, the cone points in -Y direction
+     * (apex at lower Y). The base ring is at Y=0, and the apex is at Y=-length.</p>
+     *
+     * @param endPoint  the position of the arrow tip (cone apex)
+     * @param radius    the radius of the cone base
+     * @param length    the length of the cone
+     * @param segments  the number of segments around the circumference
+     * @param color     the fill color
+     * @param rotMatrix the rotation matrix to apply
+     * @param dirX      direction X component
+     * @param dirY      direction Y component
+     * @param dirZ      direction Z component
+     */
+    private void addConeTip(final Point3D endPoint, final double radius,
+                            final double length, final int segments,
+                            final Color color, final Matrix3x3 rotMatrix,
+                            final double dirX, final double dirY, final double dirZ) {
+        // Apex is at endPoint (the arrow tip)
+        // Base center is at endPoint - length * direction (toward arrow start)
+        final double baseCenterX = endPoint.x - length * dirX;
+        final double baseCenterY = endPoint.y - length * dirY;
+        final double baseCenterZ = endPoint.z - length * dirZ;
+
+        // Generate base ring vertices
+        // In local space, cone points in -Y direction, so base is at Y=0
+        final Point3D[] baseRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Base ring vertices at local Y=0
+            final Point3D local = new Point3D(localX, 0, localZ);
+            rotMatrix.transform(local, local);
+            local.x += baseCenterX;
+            local.y += baseCenterY;
+            local.z += baseCenterZ;
+            baseRing[i] = local;
+        }
+
+        // Apex point (the arrow tip)
+        final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z);
+
+        // Create cone side faces
+        // Winding: apex → current → next creates CCW winding when viewed from outside
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            addShape(new SolidPolygon(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z),
+                    color));
+        }
+
+        // Create base cap of the cone tip (fills the gap between cone and cylinder body)
+        // Single N-vertex polygon that closes the loop to create segments triangles
+        // (segments+2 vertices → segments triangles via fan triangulation)
+        // The base cap faces toward the arrow body/start, opposite to the cone's pointing direction.
+        // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1]
+        final Point3D baseCenter = new Point3D(baseCenterX, baseCenterY, baseCenterZ);
+        final Point3D[] tipBaseCapVertices = new Point3D[segments + 2];
+        tipBaseCapVertices[0] = baseCenter;
+        for (int i = 0; i < segments; i++) {
+            tipBaseCapVertices[i + 1] = baseRing[segments - 1 - i];
+        }
+        tipBaseCapVertices[segments + 1] = baseRing[segments - 1]; // close the loop
+        addShape(new SolidPolygon(tipBaseCapVertices, color));
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java
new file mode 100644 (file)
index 0000000..740b5b9
--- /dev/null
@@ -0,0 +1,268 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A solid cone that can be oriented in any direction.
+ *
+ * <p>The cone has a circular base and a single apex (tip) point. Two constructors
+ * are provided for different use cases:</p>
+ *
+ * <ul>
+ *   <li><b>Directional (recommended):</b> Specify apex point and base center point.
+ *       The cone points from apex toward the base center. This allows arbitrary
+ *       orientation and is the most intuitive API.</li>
+ *   <li><b>Y-axis aligned:</b> Specify base center, radius, and height. The cone
+ *       points in -Y direction (apex at lower Y). Useful for simple vertical cones.</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Directional constructor: cone pointing from apex toward base
+ * SolidPolygonCone directionalCone = new SolidPolygonCone(
+ *     new Point3D(0, -100, 0),   // apex (tip of the cone)
+ *     new Point3D(0, 50, 0),     // baseCenter (cone points toward this)
+ *     50,                        // radius of the circular base
+ *     16,                        // segments
+ *     Color.RED
+ * );
+ *
+ * // Y-axis aligned constructor: cone pointing upward
+ * SolidPolygonCone verticalCone = new SolidPolygonCone(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // radius
+ *     100,                       // height
+ *     16,                        // segments
+ *     Color.RED
+ * );
+ * }</pre>
+ *
+ * @see SolidPolygonCylinder
+ * @see SolidPolygonArrow
+ * @see SolidPolygon
+ */
+public class SolidPolygonCone extends AbstractCompositeShape {
+
+    /**
+     * Constructs a solid cone pointing from apex toward base center.
+     *
+     * <p>This is the recommended constructor for placing cones in 3D space.
+     * The cone's apex (tip) is at {@code apexPoint}, and the circular base
+     * is centered at {@code baseCenterPoint}. The cone points in the direction
+     * from apex to base center.</p>
+     *
+     * <p><b>Coordinate interpretation:</b></p>
+     * <ul>
+     *   <li>{@code apexPoint} - the sharp tip of the cone</li>
+     *   <li>{@code baseCenterPoint} - the center of the circular base; the cone
+     *       "points" in this direction from the apex</li>
+     *   <li>The distance between apex and base center determines the cone height</li>
+     * </ul>
+     *
+     * @param apexPoint       the position of the cone's tip (apex)
+     * @param baseCenterPoint the center point of the circular base; the cone
+     *                        points from apex toward this point
+     * @param radius          the radius of the circular base
+     * @param segments        the number of segments around the circumference.
+     *                        Higher values create smoother cones. Minimum is 3.
+     * @param color           the fill color applied to all faces of the cone
+     */
+    public SolidPolygonCone(final Point3D apexPoint, final Point3D baseCenterPoint,
+                            final double radius, final int segments,
+                            final Color color) {
+        super();
+
+        // Calculate direction and height from apex to base center
+        final double dx = baseCenterPoint.x - apexPoint.x;
+        final double dy = baseCenterPoint.y - apexPoint.y;
+        final double dz = baseCenterPoint.z - apexPoint.z;
+        final double height = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: apex and base center are the same point
+        if (height < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector (from apex toward base)
+        final double nx = dx / height;
+        final double ny = dy / height;
+        final double nz = dz / height;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default cone points in -Y direction (apex at origin, base at -Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Generate base ring vertices in local space, then rotate and translate
+        // In local space: apex is at origin, base is at Y = -height
+        // (cone points in -Y direction in local space)
+        final Point3D[] baseRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Base ring vertex in local space (Y = -height)
+            final Point3D local = new Point3D(localX, -height, localZ);
+            rotMatrix.transform(local, local);
+            local.x += apexPoint.x;
+            local.y += apexPoint.y;
+            local.z += apexPoint.z;
+            baseRing[i] = local;
+        }
+
+        // Apex point (the cone tip)
+        final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z);
+
+        // Create side faces connecting each pair of adjacent base vertices to the apex
+        // Winding: apex → next → current creates CCW winding when viewed from outside
+        // (Base ring vertices go CCW when viewed from apex looking at base, so we reverse)
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            addShape(new SolidPolygon(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    color));
+        }
+
+        // Create base cap (circular bottom face)
+        // Single N-vertex polygon that closes the loop to create segments triangles
+        // (segments+2 vertices → segments triangles via fan triangulation)
+        // The cap faces away from the apex (in the direction the cone points).
+        // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0]
+        final Point3D[] baseCapVertices = new Point3D[segments + 2];
+        baseCapVertices[0] = baseCenterPoint;
+        for (int i = 0; i < segments; i++) {
+            baseCapVertices[i + 1] = baseRing[i];
+        }
+        baseCapVertices[segments + 1] = baseRing[0]; // close the loop
+        addShape(new SolidPolygon(baseCapVertices, color));
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Constructs a solid cone with circular base centered at the given point,
+     * pointing in the -Y direction.
+     *
+     * <p>This constructor creates a Y-axis aligned cone. The apex is positioned
+     * at {@code baseCenter.y - height} (above the base in the negative Y direction).
+     * For cones pointing in arbitrary directions, use
+     * {@link #SolidPolygonCone(Point3D, Point3D, double, int, Color)} instead.</p>
+     *
+     * <p><b>Coordinate system:</b> The cone points in -Y direction (apex at lower Y).
+     * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height.
+     * In Aukio 3D's coordinate system, "up" visually is negative Y.</p>
+     *
+     * @param baseCenter the center point of the cone's circular base in 3D space
+     * @param radius     the radius of the circular base
+     * @param height     the height of the cone from base center to apex
+     * @param segments   the number of segments around the circumference.
+     *                   Higher values create smoother cones. Minimum is 3.
+     * @param color      the fill color applied to all faces of the cone
+     */
+    public SolidPolygonCone(final Point3D baseCenter, final double radius,
+                            final double height, final int segments,
+                            final Color color) {
+        super();
+
+        // Apex is above the base (negative Y direction in this coordinate system)
+        final double apexY = baseCenter.y - height;
+        final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z);
+
+        // Generate vertices around the circular base
+        // Vertices are ordered counter-clockwise when viewed from above (from +Y)
+        final Point3D[] baseRing = new Point3D[segments];
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double x = baseCenter.x + radius * Math.cos(angle);
+            final double z = baseCenter.z + radius * Math.sin(angle);
+            baseRing[i] = new Point3D(x, baseCenter.y, z);
+        }
+
+        // Create side faces connecting each pair of adjacent base vertices to the apex
+        // Winding: apex → next → current creates CCW winding when viewed from outside
+        // (Base ring vertices go CCW when viewed from apex looking at base, so we reverse)
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            addShape(new SolidPolygon(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    color));
+        }
+
+        // Create base cap (circular bottom face)
+        // Single N-vertex polygon that closes the loop to create segments triangles
+        // (segments+2 vertices → segments triangles via fan triangulation)
+        // The base cap faces in +Y direction (downward, away from apex).
+        // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0]
+        final Point3D[] baseCapVertices = new Point3D[segments + 2];
+        baseCapVertices[0] = baseCenter;
+        for (int i = 0; i < segments; i++) {
+            baseCapVertices[i + 1] = baseRing[i];
+        }
+        baseCapVertices[segments + 1] = baseRing[0]; // close the loop
+        addShape(new SolidPolygon(baseCapVertices, color));
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The cone by default points in the -Y direction (apex at origin, base at -Y).
+     * This method computes the rotation needed to align the cone with the target
+     * direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java
new file mode 100755 (executable)
index 0000000..e55eef6
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * A solid cube centered at a given point with equal side length along all axes.
+ * This is a convenience subclass of {@link SolidPolygonRectangularBox} that
+ * constructs a cube from a center point and a half-side length.
+ *
+ * <p>The cube extends {@code size} units in each direction from the center,
+ * resulting in a total edge length of {@code 2 * size}.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * SolidPolygonCube cube = new SolidPolygonCube(
+ *         new Point3D(0, 0, 300), 50, Color.GREEN);
+ * shapeCollection.addShape(cube);
+ * }</pre>
+ *
+ * @see SolidPolygonRectangularBox
+ * @see Color
+ */
+public class SolidPolygonCube extends SolidPolygonRectangularBox {
+
+    /**
+     * Constructs a solid cube centered at the given point.
+     *
+     * @param center the center point of the cube in 3D space
+     * @param size   the half-side length; the cube extends this distance from
+     *               the center along each axis, giving a total edge length of
+     *               {@code 2 * size}
+     * @param color  the fill color applied to all faces of the cube
+     */
+    public SolidPolygonCube(final Point3D center, final double size,
+                            final Color color) {
+        super(new Point3D(center.x - size, center.y - size, center.z - size),
+                new Point3D(center.x + size, center.y + size, center.z + size),
+                color);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java
new file mode 100644 (file)
index 0000000..3fd7b64
--- /dev/null
@@ -0,0 +1,200 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A solid cylinder defined by two end points.
+ *
+ * <p>The cylinder extends from startPoint to endPoint with circular caps at both
+ * ends. The number of segments determines the smoothness of the curved surface.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a vertical cylinder from Y=100 to Y=200
+ * SolidPolygonCylinder cylinder = new SolidPolygonCylinder(
+ *     new Point3D(0, 100, 0),   // start point (bottom)
+ *     new Point3D(0, 200, 0),   // end point (top)
+ *     10,                        // radius
+ *     16,                        // segments
+ *     Color.RED                  // color
+ * );
+ *
+ * // Create a horizontal cylinder along X axis
+ * SolidPolygonCylinder pipe = new SolidPolygonCylinder(
+ *     new Point3D(-50, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     5, 12, Color.BLUE
+ * );
+ * }</pre>
+ *
+ * @see SolidPolygonCone
+ * @see SolidPolygonArrow
+ * @see SolidPolygon
+ */
+public class SolidPolygonCylinder extends AbstractCompositeShape {
+
+    /**
+     * Constructs a solid cylinder between two end points.
+     *
+     * <p>The cylinder has circular caps at both startPoint and endPoint,
+     * connected by a curved side surface. The orientation is automatically
+     * calculated from the direction between the two points.</p>
+     *
+     * @param startPoint the center of the first cap
+     * @param endPoint   the center of the second cap
+     * @param radius     the radius of the cylinder
+     * @param segments   the number of segments around the circumference.
+     *                   Higher values create smoother cylinders. Minimum is 3.
+     * @param color      the fill color applied to all polygons
+     */
+    public SolidPolygonCylinder(final Point3D startPoint, final Point3D endPoint,
+                                final double radius, final int segments,
+                                final Color color) {
+        super();
+
+        // Calculate direction and distance
+        final double dx = endPoint.x - startPoint.x;
+        final double dy = endPoint.y - startPoint.y;
+        final double dz = endPoint.z - startPoint.z;
+        final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: start and end are the same point
+        if (distance < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector
+        final double nx = dx / distance;
+        final double ny = dy / distance;
+        final double nz = dz / distance;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default cylinder is aligned along Y-axis
+        // We need to rotate from (0, 1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Cylinder center is at midpoint between start and end
+        final double centerX = (startPoint.x + endPoint.x) / 2.0;
+        final double centerY = (startPoint.y + endPoint.y) / 2.0;
+        final double centerZ = (startPoint.z + endPoint.z) / 2.0;
+        final double halfLength = distance / 2.0;
+
+        // Generate ring vertices in local space, then rotate and translate
+        // In local space: cylinder is aligned along Y-axis
+        //   - startSideRing is at local -Y (toward startPoint)
+        //   - endSideRing is at local +Y (toward endPoint)
+        final Point3D[] startSideRing = new Point3D[segments];
+        final Point3D[] endSideRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Start-side ring (at -halfLength in local Y = toward startPoint)
+            final Point3D startLocal = new Point3D(localX, -halfLength, localZ);
+            rotMatrix.transform(startLocal, startLocal);
+            startLocal.x += centerX;
+            startLocal.y += centerY;
+            startLocal.z += centerZ;
+            startSideRing[i] = startLocal;
+
+            // End-side ring (at +halfLength in local Y = toward endPoint)
+            final Point3D endLocal = new Point3D(localX, halfLength, localZ);
+            rotMatrix.transform(endLocal, endLocal);
+            endLocal.x += centerX;
+            endLocal.y += centerY;
+            endLocal.z += centerZ;
+            endSideRing[i] = endLocal;
+        }
+
+        // Create side faces (one quad per segment)
+        // Winding: startSide[i] → endSide[i] → endSide[next] → startSide[next]
+        // creates CCW winding when viewed from outside the cylinder
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            addShape(SolidPolygon.quad(
+                    startSideRing[i],
+                    endSideRing[i],
+                    endSideRing[next],
+                    startSideRing[next],
+                    color));
+        }
+
+        // Create start cap (at startPoint, faces outward from cylinder)
+        // Single N-vertex polygon that closes the loop to create segments triangles
+        // (segments+2 vertices → segments triangles via fan triangulation)
+        // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0]
+        final Point3D[] startCapVertices = new Point3D[segments + 2];
+        startCapVertices[0] = startPoint;
+        for (int i = 0; i < segments; i++) {
+            startCapVertices[i + 1] = startSideRing[i];
+        }
+        startCapVertices[segments + 1] = startSideRing[0]; // close the loop
+        addShape(new SolidPolygon(startCapVertices, color));
+
+        // Create end cap (at endPoint, faces outward from cylinder)
+        // Reverse winding for opposite-facing cap
+        // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1]
+        final Point3D[] endCapVertices = new Point3D[segments + 2];
+        endCapVertices[0] = endPoint;
+        for (int i = 0; i < segments; i++) {
+            endCapVertices[i + 1] = endSideRing[segments - 1 - i];
+        }
+        endCapVertices[segments + 1] = endSideRing[segments - 1]; // close the loop
+        addShape(new SolidPolygon(endCapVertices, color));
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Creates a quaternion that rotates from the +Y axis to the given direction.
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is +Y (0, 1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + 1*ny + 0*nz = ny
+        final double dot = ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly +Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly -Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx)
+        // This gives the rotation axis
+        final double axisX = nz;
+        final double axisY = 0;
+        final double axisZ = -nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java
new file mode 100644 (file)
index 0000000..885f285
--- /dev/null
@@ -0,0 +1,61 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+import java.util.List;
+
+/**
+ * A renderable mesh composed of SolidPolygon triangles.
+ *
+ * <p>This is a generic composite shape that holds a collection of triangles.
+ * It can be constructed from any source of triangles, such as procedural
+ * geometry generation or loaded mesh data.</p>
+ *
+ * <p><b>Usage:</b></p>
+ * <pre>{@code
+ * // From list of triangles
+ * List<SolidPolygon> triangles = ...;
+ * SolidPolygonMesh mesh = new SolidPolygonMesh(triangles, location);
+ *
+ * // With fluent configuration
+ * shapes.addShape(mesh.setShadingEnabled(true).setBackfaceCulling(true));
+ * }</pre>
+ *
+ * @see SolidPolygon the triangle type for rendering
+ */
+public class SolidPolygonMesh extends AbstractCompositeShape {
+
+    private int triangleCount;
+
+    /**
+     * Creates a mesh from a list of SolidPolygon triangles.
+     *
+     * @param triangles the triangles to include in the mesh
+     * @param location   the position in 3D space
+     */
+    public SolidPolygonMesh(final List<SolidPolygon> triangles, final Point3D location) {
+        super(location);
+        this.triangleCount = 0;
+
+        for (final SolidPolygon triangle : triangles) {
+            addShape(triangle);
+            triangleCount++;
+        }
+    }
+
+    /**
+     * Returns the number of triangles in this mesh.
+     *
+     * @return the triangle count
+     */
+    public int getTriangleCount() {
+        return triangleCount;
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java
new file mode 100644 (file)
index 0000000..90e51d3
--- /dev/null
@@ -0,0 +1,258 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A solid square-based pyramid that can be oriented in any direction.
+ *
+ * <p>The pyramid has a square base and four triangular faces meeting at an apex
+ * (tip). Two constructors are provided for different use cases:</p>
+ *
+ * <ul>
+ *   <li><b>Directional (recommended):</b> Specify apex point and base center point.
+ *       The pyramid points from apex toward the base center. This allows arbitrary
+ *       orientation and is the most intuitive API.</li>
+ *   <li><b>Y-axis aligned:</b> Specify base center, base size, and height. The pyramid
+ *       points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Directional constructor: pyramid pointing from apex toward base
+ * SolidPolygonPyramid directionalPyramid = new SolidPolygonPyramid(
+ *     new Point3D(0, -100, 0),   // apex (tip of the pyramid)
+ *     new Point3D(0, 50, 0),     // baseCenter (pyramid points toward this)
+ *     50,                        // baseSize (half-width of square base)
+ *     Color.RED
+ * );
+ *
+ * // Y-axis aligned constructor: pyramid pointing upward
+ * SolidPolygonPyramid verticalPyramid = new SolidPolygonPyramid(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // baseSize (half-width of square base)
+ *     100,                       // height
+ *     Color.BLUE
+ * );
+ * }</pre>
+ *
+ * @see SolidPolygonCone
+ * @see SolidPolygonCube
+ * @see SolidPolygon
+ */
+public class SolidPolygonPyramid extends AbstractCompositeShape {
+
+    /**
+     * Constructs a solid square-based pyramid pointing from apex toward base center.
+     *
+     * <p>This is the recommended constructor for placing pyramids in 3D space.
+     * The pyramid's apex (tip) is at {@code apexPoint}, and the square base
+     * is centered at {@code baseCenter}. The pyramid points in the direction
+     * from apex to base center.</p>
+     *
+     * <p><b>Coordinate interpretation:</b></p>
+     * <ul>
+     *   <li>{@code apexPoint} - the sharp tip of the pyramid</li>
+     *   <li>{@code baseCenter} - the center of the square base; the pyramid
+     *       "points" in this direction from the apex</li>
+     *   <li>{@code baseSize} - half the width of the square base; the base
+     *       extends this distance from the center along perpendicular axes</li>
+     *   <li>The distance between apex and base center determines the pyramid height</li>
+     * </ul>
+     *
+     * @param apexPoint  the position of the pyramid's tip (apex)
+     * @param baseCenter the center point of the square base; the pyramid
+     *                   points from apex toward this point
+     * @param baseSize   the half-width of the square base; the base extends
+     *                   this distance from the center, giving a total base
+     *                   edge length of {@code 2 * baseSize}
+     * @param color      the fill color applied to all faces of the pyramid
+     */
+    public SolidPolygonPyramid(final Point3D apexPoint, final Point3D baseCenter,
+                               final double baseSize, final Color color) {
+        super();
+
+        // Calculate direction and height from apex to base center
+        final double dx = baseCenter.x - apexPoint.x;
+        final double dy = baseCenter.y - apexPoint.y;
+        final double dz = baseCenter.z - apexPoint.z;
+        final double height = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: apex and base center are the same point
+        if (height < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector (from apex toward base)
+        final double nx = dx / height;
+        final double ny = dy / height;
+        final double nz = dz / height;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default pyramid points in -Y direction (apex at origin, base at -Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Generate base corner vertices in local space, then rotate and translate
+        // In local space: apex is at origin, base is at Y = -height
+        // Base corners form a square centered at (0, -height, 0)
+        final double h = baseSize;
+        final Point3D[] baseCorners = new Point3D[4];
+
+        // Local space corner positions (before rotation)
+        // Arranged clockwise when viewed from apex (from +Y)
+        final double[][] localCorners = {
+                {-h, -height, -h},  // corner 0: negative X, negative Z
+                {+h, -height, -h},  // corner 1: positive X, negative Z
+                {+h, -height, +h},  // corner 2: positive X, positive Z
+                {-h, -height, +h}   // corner 3: negative X, positive Z
+        };
+
+        for (int i = 0; i < 4; i++) {
+            final Point3D local = new Point3D(localCorners[i][0], localCorners[i][1], localCorners[i][2]);
+            rotMatrix.transform(local, local);
+            local.x += apexPoint.x;
+            local.y += apexPoint.y;
+            local.z += apexPoint.z;
+            baseCorners[i] = local;
+        }
+
+        // Apex point (the pyramid tip)
+        final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z);
+
+        // Create the four triangular faces connecting apex to base edges
+        // Winding: next → current → apex creates CCW winding when viewed from outside
+        // (Base corners go CW when viewed from apex, so we reverse to get outward normals)
+        for (int i = 0; i < 4; i++) {
+            final int next = (i + 1) % 4;
+            addShape(new SolidPolygon(
+                    new Point3D(baseCorners[next].x, baseCorners[next].y, baseCorners[next].z),
+                    new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z),
+                    new Point3D(apex.x, apex.y, apex.z),
+                    color));
+        }
+
+        // Create base cap (square bottom face with center)
+        // Single N-vertex polygon that closes the loop to create 4 triangles
+        // (6 vertices → 4 triangles via fan triangulation)
+        // The cap faces away from the apex (in the direction the pyramid points).
+        // Winding: center → corner[3] → corner[0] → corner[1] → corner[2] → corner[3]
+        // (CW when viewed from apex, CCW when viewed from base side)
+        final Point3D[] baseCapVertices = new Point3D[6];
+        baseCapVertices[0] = baseCenter;
+        baseCapVertices[1] = baseCorners[3];
+        baseCapVertices[2] = baseCorners[0];
+        baseCapVertices[3] = baseCorners[1];
+        baseCapVertices[4] = baseCorners[2];
+        baseCapVertices[5] = baseCorners[3]; // close the loop
+        addShape(new SolidPolygon(baseCapVertices, color));
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Constructs a solid square-based pyramid with base centered at the given point,
+     * pointing in the -Y direction.
+     *
+     * <p>This constructor creates a Y-axis aligned pyramid. The apex is positioned
+     * at {@code baseCenter.y - height} (above the base in the negative Y direction).
+     * For pyramids pointing in arbitrary directions, use
+     * {@link #SolidPolygonPyramid(Point3D, Point3D, double, Color)} instead.</p>
+     *
+     * <p><b>Coordinate system:</b> The pyramid points in -Y direction (apex at lower Y).
+     * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height.
+     * In Aukio 3D's coordinate system, "up" visually is negative Y.</p>
+     *
+     * @param baseCenter the center point of the pyramid's base in 3D space
+     * @param baseSize   the half-width of the square base; the base extends
+     *                   this distance from the center along X and Z axes,
+     *                   giving a total base edge length of {@code 2 * baseSize}
+     * @param height     the height of the pyramid from base center to apex
+     * @param color      the fill color applied to all faces of the pyramid
+     */
+    public SolidPolygonPyramid(final Point3D baseCenter, final double baseSize,
+                               final double height, final Color color) {
+        super();
+
+        final double halfBase = baseSize;
+        final double apexY = baseCenter.y - height;
+        final double baseY = baseCenter.y;
+
+        // Base corners arranged clockwise when viewed from above (+Y)
+        // Naming: "negative/positive X" and "negative/positive Z" relative to base center
+        final Point3D negXnegZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z - halfBase);
+        final Point3D posXnegZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z - halfBase);
+        final Point3D posXposZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z + halfBase);
+        final Point3D negXposZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z + halfBase);
+        final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z);
+
+        // Four triangular faces from apex to base edges
+        // Winding: apex → current → next creates CCW when viewed from outside
+        addShape(new SolidPolygon(negXnegZ, posXnegZ, apex, color));
+        addShape(new SolidPolygon(posXnegZ, posXposZ, apex, color));
+        addShape(new SolidPolygon(posXposZ, negXposZ, apex, color));
+        addShape(new SolidPolygon(negXposZ, negXnegZ, apex, color));
+
+        // Base cap (square bottom face)
+        // Single quad using the 4 corner vertices
+        // Cap faces +Y (downward, away from apex). The base is at higher Y than apex.
+        // For outward normal (+Y direction), we need CCW ordering when viewed from +Y.
+        // Quad order: negXposZ → posXposZ → posXnegZ → negXnegZ (CCW from +Y)
+        addShape(SolidPolygon.quad(negXposZ, posXposZ, posXnegZ, negXnegZ, color));
+
+        setBackfaceCulling(true);
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The pyramid by default points in the -Y direction (apex at origin, base at -Y).
+     * This method computes the rotation needed to align the pyramid with the target
+     * direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java
new file mode 100755 (executable)
index 0000000..38e5856
--- /dev/null
@@ -0,0 +1,122 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A solid (filled) rectangular box composed of 6 quadrilateral polygons (1 per face,
+ * covering all 6 faces).
+ *
+ * <p>The box is defined by two diagonally opposite corner points in 3D space.
+ * The box is axis-aligned, meaning its edges are parallel to the X, Y, and Z axes.</p>
+ *
+ * <p><b>Vertex layout:</b></p>
+ * <pre>
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * </pre>
+ *
+ * <p>The eight vertices are derived from the two corner points:</p>
+ * <ul>
+ *   <li>Corner A defines minimum X, Y, Z</li>
+ *   <li>Corner B defines maximum X, Y, Z</li>
+ *   <li>The other 6 vertices are computed from combinations of these coordinates</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Create a box from two opposite corners
+ * SolidPolygonRectangularBox box = new SolidPolygonRectangularBox(
+ *     new Point3D(-50, -25, 100),  // cornerA (minimum X, Y, Z)
+ *     new Point3D(50, 25, 200),    // cornerB (maximum X, Y, Z)
+ *     Color.BLUE
+ * );
+ *
+ * // Create a cube using center + size (see SolidPolygonCube for convenience)
+ * double size = 50;
+ * SolidPolygonRectangularBox cube = new SolidPolygonRectangularBox(
+ *     new Point3D(0 - size, 0 - size, 200 - size),  // cornerA
+ *     new Point3D(0 + size, 0 + size, 200 + size),  // cornerB
+ *     Color.RED
+ * );
+ * }</pre>
+ *
+ * @see SolidPolygonCube
+ * @see SolidPolygon
+ */
+public class SolidPolygonRectangularBox extends AbstractCompositeShape {
+
+    /**
+     * Constructs a solid rectangular box between two diagonally opposite corner
+     * points in 3D space.
+     *
+     * <p>The box is axis-aligned and fills the rectangular region between the
+     * two corners. The corner points do not need to be ordered (cornerA can have
+     * larger coordinates than cornerB); the constructor will determine the actual
+     * min/max bounds automatically.</p>
+     *
+     * @param cornerA the first corner point (any of the 8 corners)
+     * @param cornerB the diagonally opposite corner point
+     * @param color   the fill color applied to all 6 quadrilateral polygons
+     */
+    public SolidPolygonRectangularBox(final Point3D cornerA, final Point3D cornerB, final Color color) {
+        super();
+
+        // Determine actual min/max bounds (corners may be in any order)
+        final double minX = Math.min(cornerA.x, cornerB.x);
+        final double maxX = Math.max(cornerA.x, cornerB.x);
+        final double minY = Math.min(cornerA.y, cornerB.y);
+        final double maxY = Math.max(cornerA.y, cornerB.y);
+        final double minZ = Math.min(cornerA.z, cornerB.z);
+        final double maxZ = Math.max(cornerA.z, cornerB.z);
+
+        // Compute all 8 vertices from the bounds
+        // Naming convention: min/max indicates which bound the coordinate uses
+        // minMinMin = (minX, minY, minZ), maxMaxMax = (maxX, maxY, maxZ), etc.
+        final Point3D minMinMin = new Point3D(minX, minY, minZ);
+        final Point3D maxMinMin = new Point3D(maxX, minY, minZ);
+        final Point3D maxMinMax = new Point3D(maxX, minY, maxZ);
+        final Point3D minMinMax = new Point3D(minX, minY, maxZ);
+
+        final Point3D minMaxMin = new Point3D(minX, maxY, minZ);
+        final Point3D maxMaxMin = new Point3D(maxX, maxY, minZ);
+        final Point3D minMaxMax = new Point3D(minX, maxY, maxZ);
+        final Point3D maxMaxMax = new Point3D(maxX, maxY, maxZ);
+
+        // Bottom face (y = minY) - CCW when viewed from below
+        addShape(new SolidPolygon(new Point3D[]{minMinMin, maxMinMin, maxMinMax, minMinMax}, color));
+
+        // Top face (y = maxY) - CCW when viewed from above
+        addShape(new SolidPolygon(new Point3D[]{minMaxMin, minMaxMax, maxMaxMax, maxMaxMin}, color));
+
+        // Front face (z = minZ) - CCW when viewed from front
+        addShape(new SolidPolygon(new Point3D[]{minMinMin, minMaxMin, maxMaxMin, maxMinMin}, color));
+
+        // Back face (z = maxZ) - CCW when viewed from behind
+        addShape(new SolidPolygon(new Point3D[]{maxMinMax, maxMaxMax, minMaxMax, minMinMax}, color));
+
+        // Left face (x = minX) - CCW when viewed from left
+        addShape(new SolidPolygon(new Point3D[]{minMinMin, minMinMax, minMaxMax, minMaxMin}, color));
+
+        // Right face (x = maxX) - CCW when viewed from right
+        addShape(new SolidPolygon(new Point3D[]{maxMinMin, maxMaxMin, maxMaxMax, maxMinMax}, color));
+
+        setBackfaceCulling(true);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java
new file mode 100644 (file)
index 0000000..7ebb0cb
--- /dev/null
@@ -0,0 +1,84 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A solid sphere composed of triangular polygons.
+ *
+ * <p>The sphere is constructed using a latitude-longitude grid (UV sphere).
+ * The number of segments determines the smoothness - more segments create
+ * a smoother sphere but require more polygons.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a sphere with radius 50 and 16 segments (smooth)
+ * SolidPolygonSphere sphere = new SolidPolygonSphere(
+ *     new Point3D(0, 0, 200), 50, 16, Color.RED);
+ * shapeCollection.addShape(sphere);
+ * }</pre>
+ *
+ * @see SolidPolygonCube
+ * @see SolidPolygon
+ * @see AbstractCompositeShape
+ */
+public class SolidPolygonSphere extends AbstractCompositeShape {
+
+    /**
+     * Constructs a solid sphere centered at the given point.
+     *
+     * @param center   the center point of the sphere in 3D space
+     * @param radius   the radius of the sphere
+     * @param segments the number of segments (latitude/longitude divisions).
+     *                 Higher values create smoother spheres. Minimum is 3.
+     * @param color    the fill color applied to all triangular polygons
+     */
+    public SolidPolygonSphere(final Point3D center, final double radius,
+                              final int segments, final Color color) {
+        super();
+
+        final int rings = segments;
+        final int sectors = segments * 2;
+
+        for (int i = 0; i < rings; i++) {
+            double lat0 = Math.PI * (-0.5 + (double) i / rings);
+            double lat1 = Math.PI * (-0.5 + (double) (i + 1) / rings);
+
+            for (int j = 0; j < sectors; j++) {
+                double lon0 = 2 * Math.PI * (double) j / sectors;
+                double lon1 = 2 * Math.PI * (double) (j + 1) / sectors;
+
+                Point3D p0 = sphericalToCartesian(center, radius, lat0, lon0);
+                Point3D p1 = sphericalToCartesian(center, radius, lat0, lon1);
+                Point3D p2 = sphericalToCartesian(center, radius, lat1, lon0);
+                Point3D p3 = sphericalToCartesian(center, radius, lat1, lon1);
+
+                if (i > 0) {
+                    addShape(new SolidPolygon(p0, p2, p1, color));
+                }
+
+                if (i < rings - 1) {
+                    addShape(new SolidPolygon(p2, p3, p1, color));
+                }
+            }
+        }
+
+        setBackfaceCulling(true);
+    }
+
+    private Point3D sphericalToCartesian(final Point3D center,
+                                          final double radius,
+                                          final double lat,
+                                          final double lon) {
+        double x = center.x + radius * Math.cos(lat) * Math.cos(lon);
+        double y = center.y + radius * Math.sin(lat);
+        double z = center.z + radius * Math.cos(lat) * Math.sin(lon);
+        return new Point3D(x, y, z);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/solid/package-info.java
new file mode 100644 (file)
index 0000000..0d33bac
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Solid composite shapes built from SolidTriangle primitives.
+ *
+ * <p>These shapes render as filled surfaces with optional flat shading.
+ * Useful for creating opaque 3D objects like boxes, spheres, and cylinders.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube} - A solid cube</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox} - A solid box</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere} - A solid sphere</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder} - A solid cylinder</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid} - A solid pyramid</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/SdfGlyphCache.java
new file mode 100644 (file)
index 0000000..7090806
--- /dev/null
@@ -0,0 +1,278 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas;
+
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_HEIGHT_TEXTURE_PIXELS;
+import static eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_WIDTH_TEXTURE_PIXELS;
+
+/**
+ * Per-glyph signed distance field (SDF) cache for sharp text rendering.
+ *
+ * <p>Instead of storing ink coverage (a photocopy of the glyph that blurs
+ * under any resampling), a distance field stores, per texel, the distance
+ * to the nearest glyph edge — negative inside the ink, positive outside.
+ * The rasterizer re-derives the edge per screen pixel from this smooth
+ * field, so text stays sharp at any magnification and degrades to clean
+ * gray under minification instead of aliasing.</p>
+ *
+ * <p>Each glyph is rendered at {@link #SUPERSAMPLE}x the cell resolution
+ * with anti-aliasing (giving the contour sub-texel precision), converted
+ * to a signed distance field by an exact Euclidean distance transform
+ * (Felzenszwalb &amp; Huttenlocher separable EDT), clamped to
+ * {@link #SPREAD_TEXELS} texels of gradient around the edge, and
+ * downsampled by averaging to the cell size. Results are cached per
+ * character; stamping a glyph into a canvas mask is a small block copy.</p>
+ *
+ * <p>Mask values: 0 = deep inside ink, 127/128 = the edge, 255 = far
+ * outside any glyph. The matching coverage formula lives in
+ * {@code TexturedTriangle}'s SDF scanline path.</p>
+ *
+ * @see TextCanvas
+ */
+public final class SdfGlyphCache {
+
+    /**
+     * Width of the distance gradient around the glyph edge, in
+     * primary-texture texels. The normalized mask value 0.5 +/- 0.5 spans
+     * -SPREAD..+SPREAD texels of signed distance.
+     */
+    public static final double SPREAD_TEXELS = 2.0;
+
+    /**
+     * Glyph rasterization supersampling factor relative to the cell size.
+     */
+    private static final int SUPERSAMPLE = 4;
+
+    private static final int GLYPH_W = FONT_CHAR_WIDTH_TEXTURE_PIXELS;
+    private static final int GLYPH_H = FONT_CHAR_HEIGHT_TEXTURE_PIXELS;
+    private static final int HI_W = GLYPH_W * SUPERSAMPLE;
+    private static final int HI_H = GLYPH_H * SUPERSAMPLE;
+
+    /**
+     * Signed distance clamp range in hi-res pixels.
+     */
+    private static final float SPREAD_HI = (float) (SPREAD_TEXELS * SUPERSAMPLE);
+
+    /**
+     * Hi-res scratch font, sized to fit the scratch (see static init).
+     * Liberation Mono Bold is metric-compatible with Courier New (same
+     * 0.6em advance, so the cell grid is unchanged) but sans-serif with
+     * uniform sturdy strokes — Courier's serifs and thin strokes decay
+     * into unresolvable noise when the distance field is minified. Falls
+     * back to the logical Monospaced family.
+     */
+    private static final Font FONT_HI_SIZED;
+
+    /**
+     * Baseline offset in the hi-res scratch, chosen from the font's
+     * actual metrics so the tallest glyph fits vertically centered.
+     */
+    private static final int BASELINE_HI;
+
+    static {
+        Font family = new Font("Liberation Mono", Font.BOLD, 12);
+        if (!family.getFamily().toLowerCase().contains("liberation")) {
+            family = new Font("Monospaced", Font.BOLD, 12);
+        }
+
+        // Size the font so its metrics fit the scratch with margin:
+        // advance <= HI_W (wide glyphs must not clip the cell edge, which
+        // would corrupt the distance field there) and ascent+descent <=
+        // 95% of HI_H.
+        final BufferedImage probe = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
+        final Graphics2D pg = probe.createGraphics();
+        int size = (int) (FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.066) * SUPERSAMPLE;
+        int baseline = HI_H * 3 / 4;
+        while (size > 8) {
+            final Font f = family.deriveFont((float) size);
+            pg.setFont(f);
+            final java.awt.font.FontRenderContext frc = pg.getFontRenderContext();
+            double maxAdvance = 0;
+            for (char c = 33; c < 127; c++) {
+                maxAdvance = Math.max(maxAdvance,
+                        f.getStringBounds(new char[]{c}, 0, 1, frc).getWidth());
+            }
+            final int ascent = pg.getFontMetrics().getMaxAscent();
+            final int descent = pg.getFontMetrics().getMaxDescent();
+            if (maxAdvance <= HI_W * 0.98 && ascent + descent <= HI_H * 0.95) {
+                baseline = (HI_H + ascent - descent) / 2;
+                break;
+            }
+            size--;
+        }
+        pg.dispose();
+        BASELINE_HI = baseline;
+        FONT_HI_SIZED = family.deriveFont((float) size);
+    }
+
+    private static final float INF = 1e15f;
+
+    private static final Map<Character, int[]> CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * Serializes glyph rasterization: ConcurrentHashMap.computeIfAbsent
+     * locks per bin, so two threads generating DIFFERENT glyphs could
+     * otherwise enter generate() concurrently and use the shared static
+     * FONT_HI_SIZED at the same time. libfreetype does not tolerate
+     * concurrent scaler access — observed as a SIGSEGV in
+     * FreetypeFontScaler.getGlyphMetricsNative on the pty-reader thread
+     * (aukio terminal panel) racing another text canvas.
+     */
+    private static final Object FONT_RENDER_LOCK = new Object();
+
+    private SdfGlyphCache() {
+    }
+
+    /**
+     * Returns the cached {@link #GLYPH_W} x {@link #GLYPH_H} distance
+     * field for the given character, generating it on first access.
+     * Values 0 (inside ink) .. 255 (far outside), edge at ~127.5.
+     *
+     * @param c the character
+     * @return the glyph mask (row-major, one int per texel, do not modify)
+     */
+    public static int[] glyphMask(final char c) {
+        return CACHE.computeIfAbsent(c, SdfGlyphCache::generate);
+    }
+
+    private static int[] generate(final char c) {
+        final int[] px;
+        synchronized (FONT_RENDER_LOCK) {
+            final BufferedImage scratch = new BufferedImage(HI_W, HI_H, BufferedImage.TYPE_INT_RGB);
+            final Graphics2D g = scratch.createGraphics();
+            g.setColor(java.awt.Color.BLACK);
+            g.fillRect(0, 0, HI_W, HI_H);
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g.setFont(FONT_HI_SIZED);
+            g.setColor(java.awt.Color.WHITE);
+            g.drawChars(new char[]{c}, 0, 1, 0, BASELINE_HI);
+            g.dispose();
+            px = ((DataBufferInt) scratch.getRaster().getDataBuffer()).getData();
+        }
+
+        final boolean[] ink = new boolean[HI_W * HI_H];
+        for (int i = 0; i < px.length; i++) {
+            ink[i] = (px[i] & 0xff) > 127;
+        }
+
+        // Distance to nearest ink pixel (meaningful outside the glyph)
+        // and to nearest background pixel (meaningful inside it).
+        final float[] dOut = edt(ink, HI_W, HI_H);
+        final boolean[] background = new boolean[ink.length];
+        for (int i = 0; i < ink.length; i++) {
+            background[i] = !ink[i];
+        }
+        final float[] dIn = edt(background, HI_W, HI_H);
+
+        // Signed distance, clamped to the spread and averaged down to
+        // cell resolution. Averaging the FIELD (not coverage) preserves
+        // the edge position under the downsample.
+        final int[] mask = new int[GLYPH_W * GLYPH_H];
+        for (int y = 0; y < GLYPH_H; y++) {
+            for (int x = 0; x < GLYPH_W; x++) {
+                float sum = 0;
+                for (int sy = 0; sy < SUPERSAMPLE; sy++) {
+                    final int rowBase = (y * SUPERSAMPLE + sy) * HI_W;
+                    for (int sx = 0; sx < SUPERSAMPLE; sx++) {
+                        final int i = rowBase + x * SUPERSAMPLE + sx;
+                        float signed = dOut[i] - dIn[i];
+                        if (signed > SPREAD_HI) signed = SPREAD_HI;
+                        else if (signed < -SPREAD_HI) signed = -SPREAD_HI;
+                        sum += signed;
+                    }
+                }
+                final float avg = sum / (SUPERSAMPLE * SUPERSAMPLE);
+                final int v = Math.round((avg / SPREAD_HI * 0.5f + 0.5f) * 255f);
+                mask[y * GLYPH_W + x] = 0xFF000000 | (v << 16) | (v << 8) | v;
+            }
+        }
+        return mask;
+    }
+
+    /**
+     * Exact squared Euclidean distance transform of the given feature
+     * mask via two separable 1-D passes (Felzenszwalb &amp; Huttenlocher).
+     *
+     * @param feature true at feature (zero-distance) pixels
+     * @param w       grid width
+     * @param h       grid height
+     * @return per-pixel distance to the nearest feature pixel
+     */
+    private static float[] edt(final boolean[] feature, final int w, final int h) {
+        final float[] f = new float[w * h];
+        for (int i = 0; i < f.length; i++) {
+            f[i] = feature[i] ? 0f : INF;
+        }
+
+        final int maxDim = Math.max(w, h);
+        final int[] v = new int[maxDim];
+        final float[] z = new float[maxDim + 1];
+        final float[] colIn = new float[maxDim];
+        final float[] colOut = new float[maxDim];
+
+        final float[] d = new float[w * h];
+        for (int x = 0; x < w; x++) {
+            for (int y = 0; y < h; y++) {
+                colIn[y] = f[y * w + x];
+            }
+            edt1d(colIn, colOut, h, v, z);
+            for (int y = 0; y < h; y++) {
+                d[y * w + x] = colOut[y];
+            }
+        }
+        for (int y = 0; y < h; y++) {
+            System.arraycopy(d, y * w, colIn, 0, w);
+            edt1d(colIn, colOut, w, v, z);
+            System.arraycopy(colOut, 0, d, y * w, w);
+        }
+
+        for (int i = 0; i < d.length; i++) {
+            d[i] = (float) Math.sqrt(d[i]);
+        }
+        return d;
+    }
+
+    /**
+     * 1-D squared distance transform: d[q] = min over p of
+     * (q-p)^2 + f[p], via the lower envelope of parabolas.
+     */
+    private static void edt1d(final float[] f, final float[] d, final int n,
+                              final int[] v, final float[] z) {
+        int k = 0;
+        v[0] = 0;
+        z[0] = Float.NEGATIVE_INFINITY;
+        z[1] = Float.POSITIVE_INFINITY;
+        for (int q = 1; q < n; q++) {
+            float s = ((f[q] + (float) q * q) - (f[v[k]] + (float) v[k] * v[k]))
+                    / (2f * q - 2f * v[k]);
+            while (s <= z[k]) {
+                k--;
+                s = ((f[q] + (float) q * q) - (f[v[k]] + (float) v[k] * v[k]))
+                        / (2f * q - 2f * v[k]);
+            }
+            k++;
+            v[k] = q;
+            z[k] = s;
+            z[k + 1] = Float.POSITIVE_INFINITY;
+        }
+        k = 0;
+        for (int q = 0; q < n; q++) {
+            while (z[k + 1] < q) {
+                k++;
+            }
+            final float dv = q - v[k];
+            d[q] = dv * dv + f[v[k]];
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java
new file mode 100644 (file)
index 0000000..fdfe6c7
--- /dev/null
@@ -0,0 +1,363 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas;
+
+import eu.svjatoslav.aukio.e3d.gui.TextPointer;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.TexturedRectangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+
+import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.BLACK;
+import static eu.svjatoslav.aukio.e3d.renderer.raster.Color.WHITE;
+
+/**
+ * A text rendering surface in 3D space that displays a grid of characters.
+ *
+ * <p>{@code TextCanvas} extends {@link TexturedRectangle} and renders a 2D grid of
+ * characters (rows and columns) onto a texture-mapped rectangle. Each character cell
+ * supports independent foreground and background colors.</p>
+ *
+ * <p>Characters are rendered using a monospace font at a fixed cell size
+ * ({@value #FONT_CHAR_WIDTH_TEXTURE_PIXELS} x {@value #FONT_CHAR_HEIGHT_TEXTURE_PIXELS}
+ * texture pixels per character). The texture is a signed distance field (SDF):
+ * the mask stores per-texel distance to the nearest glyph edge (generated once
+ * per character by {@link SdfGlyphCache} and stamped per cell), while the
+ * primary bitmap and a separate foreground layer carry the cell colors. The
+ * rasterizer re-derives glyph edges per screen pixel from the distance field,
+ * so text stays sharp at any zoom and any angle from a single render path.</p>
+ *
+ * <p><b>Usage example</b></p>
+ * <pre>{@code
+ * Transform location = new Transform(new Point3D(0, 0, 500));
+ * TextCanvas canvas = new TextCanvas(location, "Hello, World!",
+ *         Color.WHITE, Color.BLACK);
+ * shapeCollection.addShape(canvas);
+ *
+ * // Or create a blank canvas and write to it
+ * TextCanvas blank = new TextCanvas(location, new TextPointer(10, 40),
+ *         Color.GREEN, Color.BLACK);
+ * blank.locate(0, 0);
+ * blank.print("Line 1");
+ * blank.locate(1, 0);
+ * blank.print("Line 2");
+ * }</pre>
+ *
+ * @see SdfGlyphCache
+ * @see TexturedRectangle
+ */
+public class TextCanvas extends TexturedRectangle {
+
+    /**
+     * Font character width in world coordinates.
+     */
+    public static final int FONT_CHAR_WIDTH = 8;
+
+    /**
+     * Font character height in world coordinates.
+     */
+    public static final int FONT_CHAR_HEIGHT = 16;
+
+    /**
+      * Font character width in texture pixels.
+      */
+    public static final int FONT_CHAR_WIDTH_TEXTURE_PIXELS = 16;
+
+    /**
+     * Font character height in texture pixels.
+     */
+    public static final int FONT_CHAR_HEIGHT_TEXTURE_PIXELS = 32;
+
+    private final TextPointer size;
+    private final TextPointer cursorLocation = new TextPointer();
+    private Color backgroundColor = BLACK;
+    private Color foregroundColor = WHITE;
+
+    /**
+     * Creates a text canvas initialized with the given text string.
+     *
+     * <p>The canvas dimensions are automatically computed from the text content
+     * (number of lines determines rows, the longest line determines columns).</p>
+     *
+     * @param location        the 3D transform positioning this canvas in the scene
+     * @param text            the initial text content (may contain newlines for multiple rows)
+     * @param foregroundColor the default text color
+     * @param backgroundColor the default background color
+     */
+    public TextCanvas(final Transform location, final String text,
+                      final Color foregroundColor, final Color backgroundColor) {
+        this(location, getTextDimensions(text), foregroundColor,
+                backgroundColor);
+        setText(text);
+    }
+
+    /**
+     * Creates a blank text canvas with the specified dimensions.
+     *
+     * <p>The canvas is initialized with spaces in every cell, filled with the
+     * specified background color. Characters can be written using
+     * {@link #putChar(char)}, {@link #print(String)}, or {@link #setText(String)}.</p>
+     *
+     * @param dimensions      the grid size as a {@link TextPointer} where
+     *                        {@code row} is the number of rows and {@code column} is the number of columns
+     * @param location        the 3D transform positioning this canvas in the scene
+     * @param foregroundColor the default text color
+     * @param backgroundColor the default background color
+     */
+    public TextCanvas(final Transform location, final TextPointer dimensions,
+                      final Color foregroundColor, final Color backgroundColor) {
+        super(location);
+
+        size = dimensions;
+        final int columns = dimensions.column;
+        final int rows = dimensions.row;
+
+        this.backgroundColor = backgroundColor;
+        this.foregroundColor = foregroundColor;
+
+        // initialize underlying textured rectangle
+        initialize(
+                columns * FONT_CHAR_WIDTH,
+                rows * FONT_CHAR_HEIGHT,
+                columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS,
+                0);
+
+        // SDF layers: the distance mask carries glyph SHAPES (bilinear
+        // sampled, footprint-scaled coverage at render time), the primary
+        // bitmap is the background color layer and sdfForeground the
+        // hard ink color layer.
+        getTexture().primaryBitmap.fillColor(backgroundColor);
+
+        final Texture texture = getTexture();
+        texture.sdfMask = new TextureBitmap(
+                columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS, 1);
+        texture.sdfMask.fillColor(WHITE); // 255 = far outside any glyph
+        texture.sdfForeground = new TextureBitmap(
+                columns * FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                rows * FONT_CHAR_HEIGHT_TEXTURE_PIXELS, 1);
+        texture.sdfForeground.fillColor(foregroundColor);
+        texture.sdfSpreadTexels = SdfGlyphCache.SPREAD_TEXELS;
+    }
+
+    /**
+     * Computes the row and column dimensions needed to fit the given text.
+     *
+     * @param text the text content (may contain newlines)
+     * @return a {@link TextPointer} where {@code row} is the number of lines and
+     *         {@code column} is the length of the longest line
+     */
+    public static TextPointer getTextDimensions(final String text) {
+
+        final BufferedReader reader = new BufferedReader(new StringReader(text));
+
+        int rows = 0;
+        int columns = 0;
+
+        while (true) {
+            final String line;
+            try {
+                line = reader.readLine();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+
+            if (line == null)
+                return new TextPointer(rows, columns);
+
+            rows++;
+            columns = Math.max(columns, line.length());
+        }
+    }
+
+    /**
+     * Clears the entire canvas, resetting all characters to spaces with the default colors.
+     *
+     * <p>The SDF mask and both color layers are reset.</p>
+     */
+    public void clear() {
+        getTexture().primaryBitmap.fillColor(backgroundColor);
+        getTexture().sdfMask.fillColor(WHITE);
+        getTexture().sdfForeground.fillColor(foregroundColor);
+    }
+
+    private void drawCharToTexture(final int row, final int column,
+                                   final char character, final Color foreground) {
+        final Texture texture = getTexture();
+        final int px = column * FONT_CHAR_WIDTH_TEXTURE_PIXELS;
+        final int py = row * FONT_CHAR_HEIGHT_TEXTURE_PIXELS;
+
+        // Background and ink color layers: hard per-cell fills (sampled
+        // nearest; only where the SDF coverage selects them).
+        texture.primaryBitmap.drawRectangle(px, py,
+                px + FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                py + FONT_CHAR_HEIGHT_TEXTURE_PIXELS, backgroundColor);
+        texture.sdfForeground.drawRectangle(px, py,
+                px + FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                py + FONT_CHAR_HEIGHT_TEXTURE_PIXELS, foreground);
+
+        // Stamp the cached glyph distance field into the mask.
+        final int[] glyph = SdfGlyphCache.glyphMask(character);
+        final int[] dst = texture.sdfMask.pixels;
+        final int maskWidth = texture.sdfMask.width;
+        for (int gy = 0; gy < FONT_CHAR_HEIGHT_TEXTURE_PIXELS; gy++) {
+            System.arraycopy(glyph, gy * FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+                    dst, (py + gy) * maskWidth + px, FONT_CHAR_WIDTH_TEXTURE_PIXELS);
+        }
+    }
+
+    /**
+     * Returns the dimensions of this text canvas.
+     *
+     * @return a {@link TextPointer} where {@code row} is the number of rows
+     *         and {@code column} is the number of columns
+     */
+    public TextPointer getSize() {
+        return size;
+    }
+
+    /**
+     * Moves the internal cursor to the specified row and column.
+     *
+     * <p>Subsequent calls to {@link #putChar(char)} and {@link #print(String)} will
+     * begin writing at this position.</p>
+     *
+     * @param row    the target row (0-based)
+     * @param column the target column (0-based)
+     */
+    public void locate(final int row, final int column) {
+        cursorLocation.row = row;
+        cursorLocation.column = column;
+    }
+
+    /**
+     * Prints a string starting at the current cursor location, advancing the cursor after each character.
+     *
+     * <p>When the cursor reaches the end of a row, it wraps to the beginning of the next row.</p>
+     *
+     * @param text the text to print
+     * @see #locate(int, int)
+     */
+    public void print(final String text) {
+        for (int i = 0; i < text.length(); i++)
+            putChar(text.charAt(i));
+    }
+
+    /**
+     * Writes a character at the current cursor location and advances the cursor.
+     *
+     * <p>The cursor moves one column to the right. If it exceeds the row width,
+     * it wraps to column 0 of the next row.</p>
+     *
+     * @param character the character to write
+     */
+    public void putChar(final char character) {
+        putChar(cursorLocation, character);
+
+        cursorLocation.column++;
+        if (cursorLocation.column >= size.column) {
+            cursorLocation.column = 0;
+            cursorLocation.row++;
+        }
+    }
+
+    /**
+     * Writes a character at the specified row and column using the current foreground and background colors.
+     *
+     * <p>If the row or column is out of bounds, the call is silently ignored.</p>
+     *
+     * @param row       the row index (0-based)
+     * @param column    the column index (0-based)
+     * @param character the character to write
+     */
+    public void putChar(final int row, final int column, final char character) {
+        if (row < 0 || row >= size.row || column < 0 || column >= size.column)
+            return;
+
+        drawCharToTexture(row, column, character, foregroundColor);
+    }
+
+    /**
+     * Writes a character at the position specified by a {@link TextPointer}.
+     *
+     * @param location  the row and column position
+     * @param character the character to write
+     */
+    public void putChar(final TextPointer location, final char character) {
+        putChar(location.row, location.column, character);
+    }
+
+    /**
+     * Sets the default background color for subsequent character writes.
+     *
+     * @param backgroundColor the new background color
+     */
+    public void setBackgroundColor(
+            final eu.svjatoslav.aukio.e3d.renderer.raster.Color backgroundColor) {
+        this.backgroundColor = backgroundColor;
+    }
+
+    /**
+     * Sets the default foreground (text) color for subsequent character writes.
+     *
+     * @param foregroundColor the new foreground color
+     */
+    public void setForegroundColor(
+            final eu.svjatoslav.aukio.e3d.renderer.raster.Color foregroundColor) {
+        this.foregroundColor = foregroundColor;
+    }
+
+    /**
+     * Replaces the entire canvas content with the given multi-line text string.
+     *
+     * <p>Each line of text (separated by newlines) is written to consecutive rows,
+     * starting from row 0. Characters beyond the canvas width are ignored.</p>
+     *
+     * @param text the text to display (may contain newline characters)
+     */
+    public void setText(final String text) {
+        final BufferedReader reader = new BufferedReader(new StringReader(text));
+
+        int row = 0;
+
+        while (true) {
+            final String line;
+            try {
+                line = reader.readLine();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+
+            if (line == null)
+                return;
+
+            int column = 0;
+            for (int i = 0; i < line.length(); i++) {
+                putChar(row, column, line.charAt(i));
+                column++;
+            }
+            row++;
+        }
+    }
+
+    /**
+     * Sets the foreground color of the whole canvas.
+     *
+     * <p>Fills the SDF ink color layer; cell shapes are untouched, so
+     * text content and background colors are preserved.</p>
+     *
+     * @param color the new foreground color
+     */
+    public void setTextColor(final Color color) {
+        getTexture().sdfForeground.fillColor(color);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java
new file mode 100644 (file)
index 0000000..1e8d0f3
--- /dev/null
@@ -0,0 +1,9 @@
+/**
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ * <p>
+ *
+ * Text canvas is a 2D canvas that can be used to render text.
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java
new file mode 100644 (file)
index 0000000..8f46cc3
--- /dev/null
@@ -0,0 +1,80 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.geometry.Rectangle;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A 2D grid of line segments lying in the XY plane (Z = 0 in local space).
+ * The grid is divided into configurable numbers of cells along the X and Y axes,
+ * producing a regular rectangular mesh of lines.
+ *
+ * <p>This shape is useful for rendering floors, walls, reference planes, or any
+ * flat surface that needs a grid overlay. The grid is positioned and oriented
+ * in world space using a {@link Transform}.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * Transform transform = new Transform(new Point3D(0, 100, 0));
+ * Rectangle rect = new Rectangle(new Point2D(-500, -500), new Point2D(500, 500));
+ * LineAppearance appearance = new LineAppearance(1, Color.GRAY);
+ * Grid2D grid = new Grid2D(transform, rect, 10, 10, appearance);
+ * shapeCollection.addShape(grid);
+ * }</pre>
+ *
+ * @see Grid3D
+ * @see LineAppearance
+ * @see AbstractCompositeShape
+ */
+public class Grid2D extends AbstractCompositeShape {
+
+    /**
+     * Constructs a 2D grid in the XY plane with the specified dimensions and
+     * number of divisions.
+     *
+     * @param transform      the transform defining the grid's position and orientation
+     *                       in world space
+     * @param rectangle      the rectangular dimensions of the grid in local XY space
+     * @param xDivisionCount the number of divisions (cells) along the X axis;
+     *                       produces {@code xDivisionCount + 1} vertical lines
+     * @param yDivisionCount the number of divisions (cells) along the Y axis;
+     *                       produces {@code yDivisionCount + 1} horizontal lines
+     * @param appearance     the line appearance (color, width) used for all grid lines
+     */
+    public Grid2D(final Transform transform, final Rectangle rectangle,
+                  final int xDivisionCount, final int yDivisionCount,
+                  final LineAppearance appearance) {
+
+        super(transform);
+
+        final double stepY = rectangle.getHeight() / yDivisionCount;
+        final double stepX = rectangle.getWidth() / xDivisionCount;
+
+        for (int ySlice = 0; ySlice <= yDivisionCount; ySlice++) {
+            final double y = (ySlice * stepY) + rectangle.getLowerY();
+
+            for (int xSlice = 0; xSlice <= xDivisionCount; xSlice++) {
+                final double x = (xSlice * stepX) + rectangle.getLowerX();
+
+                final Point3D p1 = new Point3D(x, y, 0);
+                final Point3D p2 = new Point3D(x + stepX, y, 0);
+                final Point3D p3 = new Point3D(x, y + stepY, 0);
+
+                if (xSlice < xDivisionCount)
+                    addShape(appearance.getLine(p1, p2));
+
+                if (ySlice < yDivisionCount)
+                    addShape(appearance.getLine(p1, p3));
+            }
+
+        }
+
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java
new file mode 100755 (executable)
index 0000000..5adff7b
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A 3D grid of line segments filling a rectangular volume defined by two
+ * diagonally opposite corner points. Lines run along all three axes (X, Y, and Z)
+ * at regular intervals determined by the step size.
+ *
+ * <p>At each grid intersection point, up to three line segments are created
+ * (one along each axis), forming a three-dimensional lattice.</p>
+ *
+ * <p>This shape is useful for visualizing 3D space, voxel boundaries, or
+ * spatial reference grids in a scene.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.GRAY);
+ * Point3D cornerA = new Point3D(-100, -100, -100);
+ * Point3D cornerB = new Point3D(100, 100, 100);
+ * Grid3D grid = new Grid3D(cornerA, cornerB, 50, appearance);
+ * shapeCollection.addShape(grid);
+ * }</pre>
+ *
+ * @see Grid2D
+ * @see LineAppearance
+ * @see AbstractCompositeShape
+ */
+public class Grid3D extends AbstractCompositeShape {
+
+    /**
+     * Constructs a 3D grid filling the volume between two diagonally opposite
+     * corner points.
+     *
+     * <p>The corner points do not need to be in any particular min/max order;
+     * the constructor automatically normalizes them so that grid generation
+     * always proceeds from minimum to maximum coordinates.</p>
+     *
+     * @param cornerA    the first corner point defining the volume
+     * @param cornerB    the diagonally opposite corner point
+     * @param step       the spacing between grid lines along each axis; must be positive
+     * @param appearance the line appearance (color, width) used for all grid lines
+     */
+    public Grid3D(final Point3D cornerA, final Point3D cornerB, final double step,
+                  final LineAppearance appearance) {
+
+        super();
+
+        // Determine actual min/max bounds (corners may be in any order)
+        final double minX = Math.min(cornerA.x, cornerB.x);
+        final double maxX = Math.max(cornerA.x, cornerB.x);
+        final double minY = Math.min(cornerA.y, cornerB.y);
+        final double maxY = Math.max(cornerA.y, cornerB.y);
+        final double minZ = Math.min(cornerA.z, cornerB.z);
+        final double maxZ = Math.max(cornerA.z, cornerB.z);
+
+        for (double x = minX; x <= maxX; x += step) {
+            for (double y = minY; y <= maxY; y += step) {
+                for (double z = minZ; z <= maxZ; z += step) {
+
+                    final Point3D p = new Point3D(x, y, z);
+
+                    // Line along X axis
+                    if ((x + step) <= maxX) {
+                        addShape(appearance.getLine(p, new Point3D(x + step, y, z)));
+                    }
+
+                    // Line along Y axis
+                    if ((y + step) <= maxY) {
+                        addShape(appearance.getLine(p, new Point3D(x, y + step, z)));
+                    }
+
+                    // Line along Z axis
+                    if ((z + step) <= maxZ) {
+                        addShape(appearance.getLine(p, new Point3D(x, y, z + step)));
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java
new file mode 100644 (file)
index 0000000..0d4cca5
--- /dev/null
@@ -0,0 +1,321 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A 3D wireframe arrow shape composed of a cylindrical body and a conical tip.
+ *
+ * <p>The arrow points from a start point to an end point, with the tip
+ * located at the end point. The wireframe consists of:</p>
+ * <ul>
+ *   <li><b>Body:</b> Two circular rings connected by lines between corresponding vertices</li>
+ *   <li><b>Tip:</b> A circular ring at the cone base with lines to the apex</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a red arrow pointing from origin to (100, -50, 200)
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeArrow arrow = new WireframeArrow(
+ *     new Point3D(0, 0, 0),      // start point
+ *     new Point3D(100, -50, 200), // end point
+ *     8,                         // body radius
+ *     20,                        // tip radius
+ *     40,                        // tip length
+ *     16,                        // segments
+ *     appearance
+ * );
+ * shapeCollection.addShape(arrow);
+ * }</pre>
+ *
+ * @see WireframeCone
+ * @see WireframeCylinder
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonArrow
+ */
+public class WireframeArrow extends AbstractCompositeShape {
+
+/**
+ * Default number of segments for arrow smoothness.
+ */
+private static final int DEFAULT_SEGMENTS = 12;
+
+/**
+ * Default tip radius as a fraction of body radius (2.5x).
+ */
+private static final double TIP_RADIUS_FACTOR = 2.5;
+
+/**
+ * Default tip length as a fraction of body radius (5.0x).
+ */
+private static final double TIP_LENGTH_FACTOR = 5.0;
+
+/**
+ * Constructs a 3D wireframe arrow pointing from start to end with sensible defaults.
+ *
+ * <p>This simplified constructor automatically calculates the tip radius as
+ * 2.5 times the body radius, the tip length as 5 times the body radius, and
+ * uses 12 segments for smoothness. For custom tip dimensions or segment count,
+ * use the full constructor.</p>
+ *
+ * @param startPoint the origin point of the arrow (where the body starts)
+ * @param endPoint   the destination point of the arrow (where the tip points to)
+ * @param bodyRadius the radius of the cylindrical body; tip dimensions are
+ *                   calculated automatically from this value
+ * @param appearance the line appearance (color, width) used for all lines
+ */
+public WireframeArrow(final Point3D startPoint, final Point3D endPoint,
+                      final double bodyRadius, final LineAppearance appearance) {
+    this(startPoint, endPoint, bodyRadius,
+            bodyRadius * TIP_RADIUS_FACTOR,
+            bodyRadius * TIP_LENGTH_FACTOR,
+            DEFAULT_SEGMENTS, appearance);
+}
+
+/**
+ * Constructs a 3D wireframe arrow pointing from start to end with full control over all dimensions.
+ *
+ * <p>The arrow consists of a cylindrical body extending from the start point
+ * towards the end, and a conical tip at the end point. If the distance between
+ * start and end is less than or equal to the tip length, only the cone tip
+ * is rendered.</p>
+ *
+ * @param startPoint  the origin point of the arrow (where the body starts)
+ * @param endPoint    the destination point of the arrow (where the tip points to)
+ * @param bodyRadius  the radius of the cylindrical body
+ * @param tipRadius   the radius of the cone base at the tip
+ * @param tipLength   the length of the conical tip
+ * @param segments    the number of segments for cylinder and cone smoothness.
+ *                    Higher values create smoother arrows. Minimum is 3.
+ * @param appearance  the line appearance (color, width) used for all lines
+ */
+public WireframeArrow(final Point3D startPoint, final Point3D endPoint,
+                      final double bodyRadius, final double tipRadius,
+                      final double tipLength, final int segments,
+                      final LineAppearance appearance) {
+        super();
+
+        // Calculate direction and distance
+        final double dx = endPoint.x - startPoint.x;
+        final double dy = endPoint.y - startPoint.y;
+        final double dz = endPoint.z - startPoint.z;
+        final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: start and end are the same point
+        if (distance < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector
+        final double nx = dx / distance;
+        final double ny = dy / distance;
+        final double nz = dz / distance;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default arrow points in -Y direction (apex at lower Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Calculate body length (distance minus tip)
+        final double bodyLength = Math.max(0, distance - tipLength);
+
+        // Build the arrow components
+        if (bodyLength > 0) {
+            addCylinderBody(startPoint, bodyRadius, bodyLength, segments, appearance, rotMatrix, nx, ny, nz);
+        }
+        addConeTip(endPoint, tipRadius, tipLength, segments, appearance, rotMatrix, nx, ny, nz);
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The arrow by default points in the -Y direction. This method computes
+     * the rotation needed to align the arrow with the target direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+
+    /**
+     * Adds the cylindrical body of the arrow.
+     *
+     * <p><b>Local coordinate system:</b> The arrow points in -Y direction in local space.
+     * Therefore, local -Y is toward the tip (front), and local +Y is toward the start (back).</p>
+     *
+     * @param startPoint the origin of the arrow body
+     * @param radius     the radius of the cylinder
+     * @param length     the length of the cylinder
+     * @param segments   the number of segments around the circumference
+     * @param appearance the line appearance
+     * @param rotMatrix  the rotation matrix to apply
+     * @param dirX       direction X component (for translation calculation)
+     * @param dirY       direction Y component
+     * @param dirZ       direction Z component
+     */
+    private void addCylinderBody(final Point3D startPoint, final double radius,
+                                 final double length, final int segments,
+                                 final LineAppearance appearance, final Matrix3x3 rotMatrix,
+                                 final double dirX, final double dirY, final double dirZ) {
+        // Cylinder center is at startPoint + (length/2) * direction
+        final double centerX = startPoint.x + (length / 2.0) * dirX;
+        final double centerY = startPoint.y + (length / 2.0) * dirY;
+        final double centerZ = startPoint.z + (length / 2.0) * dirZ;
+
+        // Generate ring vertices in local space, then rotate and translate
+        // Arrow points in -Y direction, so:
+        //   - tipSideRing is at local -Y (toward arrow tip, front of cylinder)
+        //   - startSideRing is at local +Y (toward arrow start, back of cylinder)
+        final Point3D[] tipSideRing = new Point3D[segments];
+        final Point3D[] startSideRing = new Point3D[segments];
+
+        final double halfLength = length / 2.0;
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Tip-side ring (at -halfLength in local Y = toward arrow tip)
+            final Point3D tipSideLocal = new Point3D(localX, -halfLength, localZ);
+            rotMatrix.transform(tipSideLocal, tipSideLocal);
+            tipSideLocal.x += centerX;
+            tipSideLocal.y += centerY;
+            tipSideLocal.z += centerZ;
+            tipSideRing[i] = tipSideLocal;
+
+            // Start-side ring (at +halfLength in local Y = toward arrow start)
+            final Point3D startSideLocal = new Point3D(localX, halfLength, localZ);
+            rotMatrix.transform(startSideLocal, startSideLocal);
+            startSideLocal.x += centerX;
+            startSideLocal.y += centerY;
+            startSideLocal.z += centerZ;
+            startSideRing[i] = startSideLocal;
+        }
+
+        // Create the circular rings
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            // Tip-side ring line segment
+            addShape(appearance.getLine(
+                    new Point3D(tipSideRing[i].x, tipSideRing[i].y, tipSideRing[i].z),
+                    new Point3D(tipSideRing[next].x, tipSideRing[next].y, tipSideRing[next].z)));
+
+            // Start-side ring line segment
+            addShape(appearance.getLine(
+                    new Point3D(startSideRing[i].x, startSideRing[i].y, startSideRing[i].z),
+                    new Point3D(startSideRing[next].x, startSideRing[next].y, startSideRing[next].z)));
+        }
+
+        // Create vertical lines connecting the two rings
+        for (int i = 0; i < segments; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(tipSideRing[i].x, tipSideRing[i].y, tipSideRing[i].z),
+                    new Point3D(startSideRing[i].x, startSideRing[i].y, startSideRing[i].z)));
+        }
+    }
+
+    /**
+     * Adds the conical tip of the arrow.
+     *
+     * <p><b>Local coordinate system:</b> In local space, the cone points in -Y direction
+     * (apex at lower Y). The base ring is at Y=0, and the apex is at Y=-length.</p>
+     *
+     * @param endPoint   the position of the arrow tip (cone apex)
+     * @param radius     the radius of the cone base
+     * @param length     the length of the cone
+     * @param segments   the number of segments around the circumference
+     * @param appearance the line appearance
+     * @param rotMatrix  the rotation matrix to apply
+     * @param dirX       direction X component
+     * @param dirY       direction Y component
+     * @param dirZ       direction Z component
+     */
+    private void addConeTip(final Point3D endPoint, final double radius,
+                            final double length, final int segments,
+                            final LineAppearance appearance, final Matrix3x3 rotMatrix,
+                            final double dirX, final double dirY, final double dirZ) {
+        // Apex is at endPoint (the arrow tip)
+        // Base center is at endPoint - length * direction (toward arrow start)
+        final double baseCenterX = endPoint.x - length * dirX;
+        final double baseCenterY = endPoint.y - length * dirY;
+        final double baseCenterZ = endPoint.z - length * dirZ;
+
+        // Generate base ring vertices
+        // In local space, cone points in -Y direction, so base is at Y=0
+        final Point3D[] baseRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Base ring vertices at local Y=0
+            final Point3D local = new Point3D(localX, 0, localZ);
+            rotMatrix.transform(local, local);
+            local.x += baseCenterX;
+            local.y += baseCenterY;
+            local.z += baseCenterZ;
+            baseRing[i] = local;
+        }
+
+        // Apex point (the arrow tip)
+        final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z);
+
+        // Create the circular base ring
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+            addShape(appearance.getLine(
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z)));
+        }
+
+        // Create lines from apex to each base vertex
+        for (int i = 0; i < segments; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z)));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java
new file mode 100755 (executable)
index 0000000..a4cc4b7
--- /dev/null
@@ -0,0 +1,104 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Box;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A wireframe box (rectangular parallelepiped) composed of 12 line segments
+ * representing the edges of the box. The box is axis-aligned, defined by two
+ * diagonally opposite corner points.
+ *
+ * <p>The wireframe consists of four edges along each axis: four edges parallel
+ * to X, four parallel to Y, and four parallel to Z.</p>
+ *
+ * <p><b>Vertex layout:</b></p>
+ * <pre>
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * </pre>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LineAppearance appearance = new LineAppearance(2, Color.GREEN);
+ * Point3D cornerA = new Point3D(-50, -50, -50);
+ * Point3D cornerB = new Point3D(50, 50, 50);
+ * WireframeBox box = new WireframeBox(cornerA, cornerB, appearance);
+ * shapeCollection.addShape(box);
+ * }</pre>
+ *
+ * @see WireframeCube
+ * @see Box
+ * @see LineAppearance
+ * @see AbstractCompositeShape
+ */
+public class WireframeBox extends AbstractCompositeShape {
+
+    /**
+     * Constructs a wireframe box from a {@link Box} geometry object.
+     *
+     * @param box        the axis-aligned box defining the two opposite corners
+     * @param appearance the line appearance (color, width) used for all 12 edges
+     */
+    public WireframeBox(final Box box,
+                        final LineAppearance appearance) {
+
+        this(box.p1, box.p2, appearance);
+    }
+
+    /**
+     * Constructs a wireframe box from two diagonally opposite corner points.
+     * The corners do not need to be in any particular min/max order; the constructor
+     * uses each coordinate independently to form all eight vertices of the box.
+     *
+     * @param cornerA    the first corner point of the box
+     * @param cornerB    the diagonally opposite corner point of the box
+     * @param appearance the line appearance (color, width) used for all 12 edges
+     */
+    public WireframeBox(final Point3D cornerA, final Point3D cornerB,
+                        final LineAppearance appearance) {
+        super();
+
+        // Determine actual min/max bounds (corners may be in any order)
+        final double minX = Math.min(cornerA.x, cornerB.x);
+        final double maxX = Math.max(cornerA.x, cornerB.x);
+        final double minY = Math.min(cornerA.y, cornerB.y);
+        final double maxY = Math.max(cornerA.y, cornerB.y);
+        final double minZ = Math.min(cornerA.z, cornerB.z);
+        final double maxZ = Math.max(cornerA.z, cornerB.z);
+
+        // Generate the 12 edges of the box
+        // Four edges along X axis (varying X, fixed Y and Z)
+        addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(maxX, minY, minZ)));
+        addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(maxX, maxY, minZ)));
+        addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(maxX, minY, maxZ)));
+        addShape(appearance.getLine(new Point3D(minX, maxY, maxZ), new Point3D(maxX, maxY, maxZ)));
+
+        // Four edges along Y axis (varying Y, fixed X and Z)
+        addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, maxY, minZ)));
+        addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, maxY, minZ)));
+        addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(minX, maxY, maxZ)));
+        addShape(appearance.getLine(new Point3D(maxX, minY, maxZ), new Point3D(maxX, maxY, maxZ)));
+
+        // Four edges along Z axis (varying Z, fixed X and Y)
+        addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, minY, maxZ)));
+        addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, minY, maxZ)));
+        addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(minX, maxY, maxZ)));
+        addShape(appearance.getLine(new Point3D(maxX, maxY, minZ), new Point3D(maxX, maxY, maxZ)));
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java
new file mode 100644 (file)
index 0000000..9945e65
--- /dev/null
@@ -0,0 +1,247 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A wireframe cone that can be oriented in any direction.
+ *
+ * <p>The cone has a circular base and a single apex (tip) point. The wireframe
+ * consists of:</p>
+ * <ul>
+ *   <li>A circular ring at the base</li>
+ *   <li>Lines from each base vertex to the apex</li>
+ * </ul>
+ *
+ * <p>Two constructors are provided for different use cases:</p>
+ *
+ * <ul>
+ *   <li><b>Directional (recommended):</b> Specify apex point and base center point.
+ *       The cone points from apex toward the base center. This allows arbitrary
+ *       orientation and is the most intuitive API.</li>
+ *   <li><b>Y-axis aligned:</b> Specify base center, radius, and height. The cone
+ *       points in -Y direction (apex at lower Y). Useful for simple vertical cones.</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Directional constructor: cone pointing from apex toward base
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeCone directionalCone = new WireframeCone(
+ *     new Point3D(0, -100, 0),   // apex (tip of the cone)
+ *     new Point3D(0, 50, 0),     // baseCenter (cone points toward this)
+ *     50,                        // radius of the circular base
+ *     16,                        // segments
+ *     appearance
+ * );
+ *
+ * // Y-axis aligned constructor: cone pointing upward
+ * WireframeCone verticalCone = new WireframeCone(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // radius
+ *     100,                       // height
+ *     16,                        // segments
+ *     appearance
+ * );
+ * }</pre>
+ *
+ * @see WireframeCylinder
+ * @see WireframeArrow
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCone
+ */
+public class WireframeCone extends AbstractCompositeShape {
+
+    /**
+     * Constructs a wireframe cone pointing from apex toward base center.
+     *
+     * <p>This is the recommended constructor for placing cones in 3D space.
+     * The cone's apex (tip) is at {@code apexPoint}, and the circular base
+     * is centered at {@code baseCenterPoint}. The cone points in the direction
+     * from apex to base center.</p>
+     *
+     * <p><b>Coordinate interpretation:</b></p>
+     * <ul>
+     *   <li>{@code apexPoint} - the sharp tip of the cone</li>
+     *   <li>{@code baseCenterPoint} - the center of the circular base; the cone
+     *       "points" in this direction from the apex</li>
+     *   <li>The distance between apex and base center determines the cone height</li>
+     * </ul>
+     *
+     * @param apexPoint       the position of the cone's tip (apex)
+     * @param baseCenterPoint the center point of the circular base; the cone
+     *                        points from apex toward this point
+     * @param radius          the radius of the circular base
+     * @param segments        the number of segments around the circumference.
+     *                        Higher values create smoother cones. Minimum is 3.
+     * @param appearance      the line appearance (color, width) used for all lines
+     */
+    public WireframeCone(final Point3D apexPoint, final Point3D baseCenterPoint,
+                         final double radius, final int segments,
+                         final LineAppearance appearance) {
+        super();
+
+        // Calculate direction and height from apex to base center
+        final double dx = baseCenterPoint.x - apexPoint.x;
+        final double dy = baseCenterPoint.y - apexPoint.y;
+        final double dz = baseCenterPoint.z - apexPoint.z;
+        final double height = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: apex and base center are the same point
+        if (height < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector (from apex toward base)
+        final double nx = dx / height;
+        final double ny = dy / height;
+        final double nz = dz / height;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default cone points in -Y direction (apex at origin, base at -Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Generate base ring vertices in local space, then rotate and translate
+        // In local space: apex is at origin, base is at Y = -height
+        // (cone points in -Y direction in local space)
+        final Point3D[] baseRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Base ring vertex in local space (Y = -height)
+            final Point3D local = new Point3D(localX, -height, localZ);
+            rotMatrix.transform(local, local);
+            local.x += apexPoint.x;
+            local.y += apexPoint.y;
+            local.z += apexPoint.z;
+            baseRing[i] = local;
+        }
+
+        // Apex point (the cone tip)
+        final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z);
+
+        // Create the circular base ring
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+            addShape(appearance.getLine(
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z)));
+        }
+
+        // Create lines from apex to each base vertex
+        for (int i = 0; i < segments; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z)));
+        }
+    }
+
+    /**
+     * Constructs a wireframe cone with circular base centered at the given point,
+     * pointing in the -Y direction.
+     *
+     * <p>This constructor creates a Y-axis aligned cone. The apex is positioned
+     * at {@code baseCenter.y - height} (above the base in the negative Y direction).
+     * For cones pointing in arbitrary directions, use
+     * {@link #WireframeCone(Point3D, Point3D, double, int, LineAppearance)} instead.</p>
+     *
+     * <p><b>Coordinate system:</b> The cone points in -Y direction (apex at lower Y).
+     * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height.
+     * In Aukio 3D's coordinate system, "up" visually is negative Y.</p>
+     *
+     * @param baseCenter the center point of the cone's circular base in 3D space
+     * @param radius     the radius of the circular base
+     * @param height     the height of the cone from base center to apex
+     * @param segments   the number of segments around the circumference.
+     *                   Higher values create smoother cones. Minimum is 3.
+     * @param appearance the line appearance (color, width) used for all lines
+     */
+    public WireframeCone(final Point3D baseCenter, final double radius,
+                         final double height, final int segments,
+                         final LineAppearance appearance) {
+        super();
+
+        // Apex is above the base (negative Y direction in this coordinate system)
+        final double apexY = baseCenter.y - height;
+        final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z);
+
+        // Generate vertices around the circular base
+        final Point3D[] baseRing = new Point3D[segments];
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double x = baseCenter.x + radius * Math.cos(angle);
+            final double z = baseCenter.z + radius * Math.sin(angle);
+            baseRing[i] = new Point3D(x, baseCenter.y, z);
+        }
+
+        // Create the circular base ring
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+            addShape(appearance.getLine(
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z),
+                    new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z)));
+        }
+
+        // Create lines from apex to each base vertex
+        for (int i = 0; i < segments; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z)));
+        }
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The cone by default points in the -Y direction (apex at origin, base at -Y).
+     * This method computes the rotation needed to align the cone with the target
+     * direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java
new file mode 100755 (executable)
index 0000000..7bbbd3f
--- /dev/null
@@ -0,0 +1,45 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+
+/**
+ * A wireframe cube (equal-length sides) centered at a given point in 3D space.
+ * This is a convenience subclass of {@link WireframeBox} that constructs an
+ * axis-aligned cube from a center point and a half-side length.
+ *
+ * <p>The cube extends {@code size} units in each direction from the center,
+ * resulting in a total edge length of {@code 2 * size}.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.CYAN);
+ * WireframeCube cube = new WireframeCube(new Point3D(0, 0, 200), 50, appearance);
+ * shapeCollection.addShape(cube);
+ * }</pre>
+ *
+ * @see WireframeBox
+ * @see LineAppearance
+ */
+public class WireframeCube extends WireframeBox {
+
+    /**
+     * Constructs a wireframe cube centered at the given point.
+     *
+     * @param center     the center point of the cube in 3D space
+     * @param size       the half-side length; the cube extends this distance from
+     *                   the center along each axis, giving a total edge length
+     *                   of {@code 2 * size}
+     * @param appearance the line appearance (color, width) used for all 12 edges
+     */
+    public WireframeCube(final Point3D center, final double size,
+                         final LineAppearance appearance) {
+        super(new Point3D(center.x - size, center.y - size, center.z - size),
+                new Point3D(center.x + size, center.y + size, center.z + size),
+                appearance);
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java
new file mode 100644 (file)
index 0000000..30988fa
--- /dev/null
@@ -0,0 +1,188 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A wireframe cylinder defined by two end points.
+ *
+ * <p>The cylinder extends from startPoint to endPoint with circular rings at both
+ * ends. The number of segments determines the smoothness of the circular rings.
+ * The wireframe consists of:</p>
+ * <ul>
+ *   <li>Two circular rings at the start and end points</li>
+ *   <li>Vertical lines connecting corresponding vertices between the rings</li>
+ * </ul>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * // Create a vertical cylinder from Y=100 to Y=200
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframeCylinder cylinder = new WireframeCylinder(
+ *     new Point3D(0, 100, 0),   // start point (bottom)
+ *     new Point3D(0, 200, 0),   // end point (top)
+ *     10,                        // radius
+ *     16,                        // segments
+ *     appearance
+ * );
+ *
+ * // Create a horizontal cylinder along X axis
+ * WireframeCylinder pipe = new WireframeCylinder(
+ *     new Point3D(-50, 0, 0),
+ *     new Point3D(50, 0, 0),
+ *     5, 12, appearance
+ * );
+ * }</pre>
+ *
+ * @see WireframeCone
+ * @see WireframeArrow
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder
+ */
+public class WireframeCylinder extends AbstractCompositeShape {
+
+    /**
+     * Constructs a wireframe cylinder between two end points.
+     *
+     * <p>The cylinder has circular rings at both startPoint and endPoint,
+     * connected by lines between corresponding vertices. The orientation is
+     * automatically calculated from the direction between the two points.</p>
+     *
+     * @param startPoint the center of the first ring
+     * @param endPoint   the center of the second ring
+     * @param radius     the radius of the cylinder
+     * @param segments   the number of segments around the circumference.
+     *                   Higher values create smoother cylinders. Minimum is 3.
+     * @param appearance the line appearance (color, width) used for all lines
+     */
+    public WireframeCylinder(final Point3D startPoint, final Point3D endPoint,
+                             final double radius, final int segments,
+                             final LineAppearance appearance) {
+        super();
+
+        // Calculate direction and distance
+        final double dx = endPoint.x - startPoint.x;
+        final double dy = endPoint.y - startPoint.y;
+        final double dz = endPoint.z - startPoint.z;
+        final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: start and end are the same point
+        if (distance < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector
+        final double nx = dx / distance;
+        final double ny = dy / distance;
+        final double nz = dz / distance;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default cylinder is aligned along Y-axis
+        // We need to rotate from (0, 1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Cylinder center is at midpoint between start and end
+        final double centerX = (startPoint.x + endPoint.x) / 2.0;
+        final double centerY = (startPoint.y + endPoint.y) / 2.0;
+        final double centerZ = (startPoint.z + endPoint.z) / 2.0;
+        final double halfLength = distance / 2.0;
+
+        // Generate ring vertices in local space, then rotate and translate
+        // In local space: cylinder is aligned along Y-axis
+        //   - startRing is at local -Y (toward startPoint)
+        //   - endRing is at local +Y (toward endPoint)
+        final Point3D[] startRing = new Point3D[segments];
+        final Point3D[] endRing = new Point3D[segments];
+
+        for (int i = 0; i < segments; i++) {
+            final double angle = 2.0 * Math.PI * i / segments;
+            final double localX = radius * Math.cos(angle);
+            final double localZ = radius * Math.sin(angle);
+
+            // Start ring (at -halfLength in local Y = toward startPoint)
+            final Point3D startLocal = new Point3D(localX, -halfLength, localZ);
+            rotMatrix.transform(startLocal, startLocal);
+            startLocal.x += centerX;
+            startLocal.y += centerY;
+            startLocal.z += centerZ;
+            startRing[i] = startLocal;
+
+            // End ring (at +halfLength in local Y = toward endPoint)
+            final Point3D endLocal = new Point3D(localX, halfLength, localZ);
+            rotMatrix.transform(endLocal, endLocal);
+            endLocal.x += centerX;
+            endLocal.y += centerY;
+            endLocal.z += centerZ;
+            endRing[i] = endLocal;
+        }
+
+        // Create the circular rings
+        for (int i = 0; i < segments; i++) {
+            final int next = (i + 1) % segments;
+
+            // Start ring line segment
+            addShape(appearance.getLine(
+                    new Point3D(startRing[i].x, startRing[i].y, startRing[i].z),
+                    new Point3D(startRing[next].x, startRing[next].y, startRing[next].z)));
+
+            // End ring line segment
+            addShape(appearance.getLine(
+                    new Point3D(endRing[i].x, endRing[i].y, endRing[i].z),
+                    new Point3D(endRing[next].x, endRing[next].y, endRing[next].z)));
+        }
+
+        // Create vertical lines connecting the two rings
+        for (int i = 0; i < segments; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(startRing[i].x, startRing[i].y, startRing[i].z),
+                    new Point3D(endRing[i].x, endRing[i].y, endRing[i].z)));
+        }
+    }
+
+    /**
+     * Creates a quaternion that rotates from the +Y axis to the given direction.
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is +Y (0, 1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + 1*ny + 0*nz = ny
+        final double dot = ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly +Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly -Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx)
+        // This gives the rotation axis
+        final double axisX = nz;
+        final double axisY = 0;
+        final double axisZ = -nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java
new file mode 100755 (executable)
index 0000000..1510ea4
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A freeform polyline drawing tool that connects sequential points with line
+ * segments. Points are added one at a time via {@link #addPoint(Point3D)};
+ * each new point is connected to the previously added point by a line.
+ *
+ * <p>The first point added establishes the starting position without drawing
+ * a line. Each subsequent point creates a new line segment from the previous
+ * point to the new one.</p>
+ *
+ * <p>This shape is useful for drawing paths, trails, trajectories, or
+ * arbitrary wireframe shapes that are defined as a sequence of vertices.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LineAppearance appearance = new LineAppearance(2, Color.YELLOW);
+ * WireframeDrawing drawing = new WireframeDrawing(appearance);
+ * drawing.addPoint(new Point3D(0, 0, 0));
+ * drawing.addPoint(new Point3D(100, 50, 0));
+ * drawing.addPoint(new Point3D(200, 0, 0));
+ * shapeCollection.addShape(drawing);
+ * }</pre>
+ *
+ * @see LineAppearance
+ * @see AbstractCompositeShape
+ */
+public class WireframeDrawing extends AbstractCompositeShape {
+
+    /** The line appearance used for all segments in this drawing. */
+    final private LineAppearance lineAppearance;
+
+    /** The most recently added point, used as the start of the next line segment. */
+    Point3D currentPoint;
+
+    /**
+     * Constructs a new empty wireframe drawing with the given line appearance.
+     *
+     * @param lineAppearance the line appearance (color, width) used for all
+     *                       line segments added to this drawing
+     */
+    public WireframeDrawing(final LineAppearance lineAppearance) {
+        super();
+        this.lineAppearance = lineAppearance;
+    }
+
+    /**
+     * Adds a new point to the drawing. If this is the first point, it sets the
+     * starting position. Otherwise, a line segment is created from the previous
+     * point to this new point.
+     *
+     * <p>The point is defensively copied, so subsequent modifications to the
+     * passed {@code point3d} object will not affect the drawing.</p>
+     *
+     * @param point3d the point to add to the polyline
+     */
+    public void addPoint(final Point3D point3d) {
+        if (currentPoint != null) {
+            final Line line = lineAppearance.getLine(currentPoint, point3d);
+            addShape(line);
+        }
+
+        currentPoint = new Point3D(point3d);
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java
new file mode 100644 (file)
index 0000000..fe04179
--- /dev/null
@@ -0,0 +1,246 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.math.Matrix3x3;
+import eu.svjatoslav.aukio.e3d.math.Quaternion;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+/**
+ * A wireframe square-based pyramid that can be oriented in any direction.
+ *
+ * <p>The pyramid has a square base and four triangular faces meeting at an apex
+ * (tip). The wireframe consists of:</p>
+ * <ul>
+ *   <li>Four lines forming the square base</li>
+ *   <li>Four lines from each base corner to the apex</li>
+ * </ul>
+ *
+ * <p>Two constructors are provided for different use cases:</p>
+ *
+ * <ul>
+ *   <li><b>Directional (recommended):</b> Specify apex point and base center point.
+ *       The pyramid points from apex toward the base center. This allows arbitrary
+ *       orientation and is the most intuitive API.</li>
+ *   <li><b>Y-axis aligned:</b> Specify base center, base size, and height. The pyramid
+ *       points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.</li>
+ * </ul>
+ *
+ * <p><b>Usage examples:</b></p>
+ * <pre>{@code
+ * // Directional constructor: pyramid pointing from apex toward base
+ * LineAppearance appearance = new LineAppearance(2, Color.RED);
+ * WireframePyramid directionalPyramid = new WireframePyramid(
+ *     new Point3D(0, -100, 0),   // apex (tip of the pyramid)
+ *     new Point3D(0, 50, 0),     // baseCenter (pyramid points toward this)
+ *     50,                        // baseSize (half-width of square base)
+ *     appearance
+ * );
+ *
+ * // Y-axis aligned constructor: pyramid pointing upward
+ * WireframePyramid verticalPyramid = new WireframePyramid(
+ *     new Point3D(0, 0, 300),    // baseCenter
+ *     50,                        // baseSize (half-width of square base)
+ *     100,                       // height
+ *     appearance
+ * );
+ * }</pre>
+ *
+ * @see WireframeCone
+ * @see WireframeCube
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid
+ */
+public class WireframePyramid extends AbstractCompositeShape {
+
+    /**
+     * Constructs a wireframe square-based pyramid pointing from apex toward base center.
+     *
+     * <p>This is the recommended constructor for placing pyramids in 3D space.
+     * The pyramid's apex (tip) is at {@code apexPoint}, and the square base
+     * is centered at {@code baseCenter}. The pyramid points in the direction
+     * from apex to base center.</p>
+     *
+     * <p><b>Coordinate interpretation:</b></p>
+     * <ul>
+     *   <li>{@code apexPoint} - the sharp tip of the pyramid</li>
+     *   <li>{@code baseCenter} - the center of the square base; the pyramid
+     *       "points" in this direction from the apex</li>
+     *   <li>{@code baseSize} - half the width of the square base; the base
+     *       extends this distance from the center along perpendicular axes</li>
+     *   <li>The distance between apex and base center determines the pyramid height</li>
+     * </ul>
+     *
+     * @param apexPoint  the position of the pyramid's tip (apex)
+     * @param baseCenter the center point of the square base; the pyramid
+     *                   points from apex toward this point
+     * @param baseSize   the half-width of the square base; the base extends
+     *                   this distance from the center, giving a total base
+     *                   edge length of {@code 2 * baseSize}
+     * @param appearance the line appearance (color, width) used for all lines
+     */
+    public WireframePyramid(final Point3D apexPoint, final Point3D baseCenter,
+                            final double baseSize, final LineAppearance appearance) {
+        super();
+
+        // Calculate direction and height from apex to base center
+        final double dx = baseCenter.x - apexPoint.x;
+        final double dy = baseCenter.y - apexPoint.y;
+        final double dz = baseCenter.z - apexPoint.z;
+        final double height = Math.sqrt(dx * dx + dy * dy + dz * dz);
+
+        // Handle degenerate case: apex and base center are the same point
+        if (height < 0.001) {
+            return;
+        }
+
+        // Normalize direction vector (from apex toward base)
+        final double nx = dx / height;
+        final double ny = dy / height;
+        final double nz = dz / height;
+
+        // Calculate rotation to align Y-axis with direction
+        // Default pyramid points in -Y direction (apex at origin, base at -Y)
+        // We need to rotate from (0, -1, 0) to (nx, ny, nz)
+        final Quaternion rotation = createRotationFromYAxis(nx, ny, nz);
+        final Matrix3x3 rotMatrix = rotation.toMatrix();
+
+        // Generate base corner vertices in local space, then rotate and translate
+        // In local space: apex is at origin, base is at Y = -height
+        // Base corners form a square centered at (0, -height, 0)
+        final double h = baseSize;
+        final Point3D[] baseCorners = new Point3D[4];
+
+        // Local space corner positions (before rotation)
+        // Arranged counter-clockwise when viewed from apex (from +Y)
+        final double[][] localCorners = {
+                {-h, -height, -h},  // corner 0: negative X, negative Z
+                {+h, -height, -h},  // corner 1: positive X, negative Z
+                {+h, -height, +h},  // corner 2: positive X, positive Z
+                {-h, -height, +h}   // corner 3: negative X, positive Z
+        };
+
+        for (int i = 0; i < 4; i++) {
+            final Point3D local = new Point3D(localCorners[i][0], localCorners[i][1], localCorners[i][2]);
+            rotMatrix.transform(local, local);
+            local.x += apexPoint.x;
+            local.y += apexPoint.y;
+            local.z += apexPoint.z;
+            baseCorners[i] = local;
+        }
+
+        // Apex point (the pyramid tip)
+        final Point3D apex = new Point3D(apexPoint.x, apexPoint.y, apexPoint.z);
+
+        // Create the four lines forming the square base
+        for (int i = 0; i < 4; i++) {
+            final int next = (i + 1) % 4;
+            addShape(appearance.getLine(
+                    new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z),
+                    new Point3D(baseCorners[next].x, baseCorners[next].y, baseCorners[next].z)));
+        }
+
+        // Create the four lines from apex to each base corner
+        for (int i = 0; i < 4; i++) {
+            addShape(appearance.getLine(
+                    new Point3D(apex.x, apex.y, apex.z),
+                    new Point3D(baseCorners[i].x, baseCorners[i].y, baseCorners[i].z)));
+        }
+    }
+
+    /**
+     * Constructs a wireframe square-based pyramid with base centered at the given point,
+     * pointing in the -Y direction.
+     *
+     * <p>This constructor creates a Y-axis aligned pyramid. The apex is positioned
+     * at {@code baseCenter.y - height} (above the base in the negative Y direction).
+     * For pyramids pointing in arbitrary directions, use
+     * {@link #WireframePyramid(Point3D, Point3D, double, LineAppearance)} instead.</p>
+     *
+     * <p><b>Coordinate system:</b> The pyramid points in -Y direction (apex at lower Y).
+     * The base is at Y=baseCenter.y, and the apex is at Y=baseCenter.y - height.
+     * In Aukio 3D's coordinate system, "up" visually is negative Y.</p>
+     *
+     * @param baseCenter the center point of the pyramid's base in 3D space
+     * @param baseSize   the half-width of the square base; the base extends
+     *                   this distance from the center along X and Z axes,
+     *                   giving a total base edge length of {@code 2 * baseSize}
+     * @param height     the height of the pyramid from base center to apex
+     * @param appearance the line appearance (color, width) used for all lines
+     */
+    public WireframePyramid(final Point3D baseCenter, final double baseSize,
+                            final double height, final LineAppearance appearance) {
+        super();
+
+        final double halfBase = baseSize;
+        final double apexY = baseCenter.y - height;
+        final double baseY = baseCenter.y;
+
+        // Base corners arranged counter-clockwise when viewed from above (+Y)
+        // Naming: "negative/positive X" and "negative/positive Z" relative to base center
+        final Point3D negXnegZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z - halfBase);
+        final Point3D posXnegZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z - halfBase);
+        final Point3D posXposZ = new Point3D(baseCenter.x + halfBase, baseY, baseCenter.z + halfBase);
+        final Point3D negXposZ = new Point3D(baseCenter.x - halfBase, baseY, baseCenter.z + halfBase);
+        final Point3D apex = new Point3D(baseCenter.x, apexY, baseCenter.z);
+
+        // Create the four lines forming the square base
+        addShape(appearance.getLine(negXnegZ, posXnegZ));
+        addShape(appearance.getLine(posXnegZ, posXposZ));
+        addShape(appearance.getLine(posXposZ, negXposZ));
+        addShape(appearance.getLine(negXposZ, negXnegZ));
+
+        // Create the four lines from apex to each base corner
+        addShape(appearance.getLine(apex, negXnegZ));
+        addShape(appearance.getLine(apex, posXnegZ));
+        addShape(appearance.getLine(apex, posXposZ));
+        addShape(appearance.getLine(apex, negXposZ));
+    }
+
+    /**
+     * Creates a quaternion that rotates from the -Y axis to the given direction.
+     *
+     * <p>The pyramid by default points in the -Y direction (apex at origin, base at -Y).
+     * This method computes the rotation needed to align the pyramid with the target
+     * direction vector.</p>
+     *
+     * @param nx normalized direction X component
+     * @param ny normalized direction Y component
+     * @param nz normalized direction Z component
+     * @return quaternion representing the rotation
+     */
+    private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) {
+        // Default direction is -Y (0, -1, 0)
+        // Target direction is (nx, ny, nz)
+        // Dot product: 0*nx + (-1)*ny + 0*nz = -ny
+        final double dot = -ny;
+
+        // Check for parallel vectors
+        if (dot > 0.9999) {
+            // Direction is nearly -Y, no rotation needed
+            return Quaternion.identity();
+        }
+        if (dot < -0.9999) {
+            // Direction is nearly +Y, rotate 180° around X axis
+            return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI);
+        }
+
+        // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx)
+        // This gives the rotation axis
+        final double axisX = -nz;
+        final double axisY = 0;
+        final double axisZ = nx;
+        final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ);
+        final double normalizedAxisX = axisX / axisLength;
+        final double normalizedAxisZ = axisZ / axisLength;
+
+        // Angle from dot product
+        final double angle = Math.acos(dot);
+
+        return Quaternion.fromAxisAngle(
+                new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle);
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java
new file mode 100755 (executable)
index 0000000..0a74e97
--- /dev/null
@@ -0,0 +1,87 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+
+import java.util.ArrayList;
+
+/**
+ * A wireframe sphere approximation built from rings of connected line segments.
+ * The sphere is generated using parametric spherical coordinates, producing a
+ * latitude-longitude grid of vertices connected by lines.
+ *
+ * <p>The sphere is divided into 20 longitudinal slices and 20 latitudinal rings
+ * (using a step of {@code PI / 10} radians). Adjacent vertices within each ring
+ * are connected, and corresponding vertices between consecutive rings are also
+ * connected, forming a mesh that approximates a sphere surface.</p>
+ *
+ * <p><b>Usage example:</b></p>
+ * <pre>{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.WHITE);
+ * WireframeSphere sphere = new WireframeSphere(new Point3D(0, 0, 300), 100f, appearance);
+ * shapeCollection.addShape(sphere);
+ * }</pre>
+ *
+ * @see LineAppearance
+ * @see AbstractCompositeShape
+ */
+public class WireframeSphere extends AbstractCompositeShape {
+
+    /** Stores the vertices of the previously generated ring for inter-ring connections. */
+    ArrayList<Point3D> previousRing = new ArrayList<>();
+
+    /**
+     * Constructs a wireframe sphere at the given location with the specified radius.
+     * The sphere is approximated by a grid of line segments generated from
+     * parametric spherical coordinates.
+     *
+     * @param location    the center point of the sphere in 3D space
+     * @param radius      the radius of the sphere
+     * @param lineFactory the line appearance (color, width) used for all line segments
+     */
+    public WireframeSphere(final Point3D location, final float radius,
+                           final LineAppearance lineFactory) {
+        super(location);
+
+        final double step = Math.PI / 10;
+
+        final Point3D center = new Point3D();
+
+        int ringIndex = 0;
+
+        for (double j = 0d; j <= (Math.PI * 2); j += step) {
+
+            Point3D oldPoint = null;
+            int pointIndex = 0;
+
+            for (double i = 0; i <= (Math.PI * 2); i += step) {
+                final Point3D newPoint = new Point3D(0, 0, radius);
+                newPoint.rotate(center, i, j);
+
+                if (oldPoint != null)
+                    addShape(lineFactory.getLine(newPoint, oldPoint));
+
+                if (ringIndex > 0) {
+                    final Point3D previousRingPoint = previousRing
+                            .get(pointIndex);
+                    addShape(lineFactory.getLine(newPoint, previousRingPoint));
+
+                    previousRing.set(pointIndex, newPoint);
+                } else
+                    previousRing.add(newPoint);
+
+                oldPoint = newPoint;
+                pointIndex++;
+            }
+
+            ringIndex++;
+        }
+
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/wireframe/package-info.java
new file mode 100644 (file)
index 0000000..1a63289
--- /dev/null
@@ -0,0 +1,24 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Wireframe composite shapes built from Line primitives.
+ *
+ * <p>These shapes render as edge-only outlines, useful for visualization,
+ * debugging, and architectural-style rendering.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox} - A wireframe box</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube} - A wireframe cube</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeSphere} - A wireframe sphere</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid2D} - A 2D grid plane</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.Grid3D} - A 3D grid volume</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/package-info.java
new file mode 100644 (file)
index 0000000..d72bc51
--- /dev/null
@@ -0,0 +1,25 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Renderable shape classes for the rasterization pipeline.
+ *
+ * <p>This package contains the shape hierarchy used for 3D rendering:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape} - Base class for all shapes</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape} - Base for shapes with vertices</li>
+ * </ul>
+ *
+ * <p>Subpackages organize shapes by type:</p>
+ * <ul>
+ *   <li>{@code basic} - Primitive shapes (lines, polygons, billboards)</li>
+ *   <li>{@code composite} - Compound shapes built from primitives (boxes, grids, text)</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes;
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/Texture.java
new file mode 100644 (file)
index 0000000..92d69b9
--- /dev/null
@@ -0,0 +1,479 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.texture;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.awt.image.WritableRaster;
+
+import static java.util.Arrays.fill;
+
+/**
+ * Represents a 2D texture with mipmap support for level-of-detail rendering.
+ *
+ * <p>A {@code Texture} contains a primary bitmap at native resolution, along with
+ * cached upscaled and downscaled versions (mipmaps) that are lazily generated on demand.
+ * This mipmap chain enables efficient texture sampling at varying distances from the camera,
+ * avoiding aliasing artifacts for distant surfaces and pixelation for close-up views.</p>
+ *
+ * <p>The texture also exposes a {@link java.awt.Graphics2D} context backed by the primary
+ * bitmap's {@link java.awt.image.BufferedImage}, allowing dynamic rendering of text,
+ * shapes, or other 2D content directly onto the texture surface. Anti-aliasing is
+ * enabled by default on this graphics context.</p>
+ *
+ * <p><b>Mipmap levels</b></p>
+ * <ul>
+ *   <li><b>Primary bitmap</b> -- the native resolution; always available.</li>
+ *   <li><b>Downsampled bitmaps</b> -- up to 8 levels, each half the size of the previous.
+ *       Used when the texture is rendered at zoom levels below 1.0.</li>
+ *   <li><b>Upsampled bitmaps</b> -- configurable count (set at construction time), each
+ *       double the size of the previous. Used when the texture is rendered at zoom levels
+ *       above 2.0.</li>
+ * </ul>
+ *
+ * <p><b>Usage example</b></p>
+ * <pre>{@code
+ * Texture tex = new Texture(256, 256, 3);
+ * // Draw content using the Graphics2D context
+ * tex.graphics.setColor(java.awt.Color.RED);
+ * tex.graphics.fillRect(0, 0, 256, 256);
+ * // Invalidate cached mipmaps after modifying the primary bitmap
+ * tex.resetResampledBitmapCache();
+ * // Retrieve the appropriate mipmap for a given zoom level
+ * TextureBitmap bitmap = tex.getMipmapForScale(0.5);
+ * }</pre>
+ *
+ * @see TextureBitmap
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ */
+public class Texture {
+
+    /**
+     * When true, texture coordinates outside [0, width/height) wrap
+     * (tile) instead of clamping to the edge texel. Needed for
+     * world-planar UVs and tiled game meshes.
+     */
+    public boolean wrap;
+
+    /**
+     * When true, this texture carries meaningful alpha (cutout TEST or
+     * BLEND): in z-buffer mode its triangles paint in the second,
+     * back-to-front alpha pass — depth-tested but not depth-written, so
+     * they resolve against opaque geometry per-pixel yet keep
+     * painter-coherent overlap among themselves. Opaque-class triangles
+     * paint first, front-to-back, writing depth. Set by texture
+     * providers that know the alpha mode (default false = opaque).
+     */
+    public boolean hasAlpha;
+
+    /**
+     * The primary (native resolution) bitmap for this texture.
+     * All dynamic drawing via {@link #graphics} modifies this bitmap's backing data.
+     */
+    public final TextureBitmap primaryBitmap;
+
+    /**
+     * A {@link java.awt.Graphics2D} context for drawing 2D content onto the primary bitmap.
+     * Anti-aliasing for both geometry and text is enabled by default.
+     */
+    public final java.awt.Graphics2D graphics;
+
+    /**
+     * Cached upsampled (enlarged) versions of the primary bitmap.
+     * Index 0 is 2x the primary, index 1 is 4x, and so on.
+     * Entries are lazily populated on first access.
+     */
+    TextureBitmap[] upSampled;
+
+    /**
+     * Cached downsampled (reduced) versions of the primary bitmap.
+     * Index 0 is 1/2 the primary, index 1 is 1/4, and so on.
+     * Entries are lazily populated on first access.
+     */
+    TextureBitmap[] downSampled = new TextureBitmap[8]; // TODO: consider renaming it to mipmap to use standard terminology
+
+    /**
+     * Optional signed-distance-field mask for sharp text/vector-art
+     * rendering. When non-null, {@code TexturedTriangle} samples this
+     * mask with bilinear filtering and derives per-pixel coverage from
+     * it (edge at value ~127.5), mixing the background layer
+     * ({@link #primaryBitmap}) with {@link #sdfForeground} — instead of
+     * blending coverage stored in the bitmap itself. The mask is always
+     * sampled at primary resolution: minification is handled by widening
+     * the coverage window by the pixel footprint, so no mipmap chain is
+     * needed and there is no mip-level isosurface drift.
+     *
+     * <p>Values: 0 = deep inside ink, 255 = far outside any ink.</p>
+     */
+    public TextureBitmap sdfMask;
+
+    /**
+     * Hard-edged foreground (ink) color layer for SDF rendering. Read
+     * only where the coverage derived from {@link #sdfMask} is non-zero,
+     * so flat per-region fills are sufficient; sampled nearest.
+     */
+    public TextureBitmap sdfForeground;
+
+    /**
+     * Width of the distance gradient encoded in {@link #sdfMask}, in
+     * primary-texture texels: mask value 0.5 +/- 0.5 spans
+     * -sdfSpreadTexels..+sdfSpreadTexels of signed distance. Set by
+     * whoever generated the mask (see {@code SdfGlyphCache#SPREAD_TEXELS}).
+     */
+    public double sdfSpreadTexels = 2.0;
+
+    /**
+     * Returns whether this texture renders through the SDF path
+     * (distance-field mask + separate bg/fg color layers).
+     *
+     * @return true when an SDF mask is attached
+     */
+    public boolean isSdf() {
+        return sdfMask != null;
+    }
+
+    /**
+     * Creates a new texture with the specified dimensions and upscale capacity.
+     *
+     * <p>The underlying {@link java.awt.image.BufferedImage} is created using
+     * {@link eu.svjatoslav.aukio.e3d.gui.RenderingContext#bufferedImageType} for
+     * compatibility with the raster rendering pipeline.</p>
+     *
+     * @param width      the width of the primary bitmap in pixels
+     * @param height     the height of the primary bitmap in pixels
+     * @param maxUpscale the maximum number of upscaled mipmap levels to support
+     *                   (each level doubles the resolution)
+     */
+    public Texture(final int width, final int height, final int maxUpscale) {
+        upSampled = new TextureBitmap[maxUpscale];
+
+        final BufferedImage bufferedImage = new BufferedImage(width, height,
+                BufferedImage.TYPE_INT_ARGB);
+
+        final WritableRaster raster = bufferedImage.getRaster();
+        final DataBufferInt dbi = (DataBufferInt) raster.getDataBuffer();
+        graphics = (Graphics2D) bufferedImage.getGraphics();
+
+        graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
+                RenderingHints.VALUE_ANTIALIAS_ON);
+
+        graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
+                RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+        primaryBitmap = new TextureBitmap(width, height, dbi.getData(), 1);
+    }
+
+/**
+     * Determines the appropriate downscale mipmap level for a given scale factor.
+     *
+     * <p>Iterates through the downscaled mipmap levels (each halving the size)
+     * and returns the index of the first level whose effective size falls below
+     * the requested scale.</p>
+     *
+     * @param scale the scale factor (typically less than 1.0 for downscaling)
+     * @return the index into the {@code downSampled} array to use, clamped to the
+     *         maximum available level
+     */
+    public int getDownscaleMipmapLevel(final double scale) {
+        double size = 1;
+        for (int i = 0; i < downSampled.length; i++) {
+            size = size / 2;
+            if (size < scale)
+                return i;
+        }
+
+        return downSampled.length - 1;
+    }
+
+    /**
+     * Determines the appropriate upscale mipmap level for a given scale factor.
+     *
+     * <p>Iterates through the upscaled mipmap levels (each doubling the size)
+     * and returns the index of the first level whose effective size exceeds
+     * the requested scale.</p>
+     *
+     * @param scale the scale factor (typically greater than 2.0 for upscaling)
+     * @return the index into the {@code upSampled} array to use, or -1 if no
+     *         upscale is needed or available
+     */
+    public int getUpscaleMipmapLevel(final double scale) {
+        double size = 2;
+        for (int i = 0; i < upSampled.length; i++) {
+            size = size * 2;
+            if (size > scale)
+                return i;
+        }
+
+        return -1;
+    }
+
+    /**
+     * Downscale given bitmap by factor of 2.
+     *
+     * @param originalBitmap Bitmap to downscale.
+     * @return Downscaled bitmap.
+     */
+    public TextureBitmap downscaleBitmap(final TextureBitmap originalBitmap) {
+        int newWidth = originalBitmap.width / 2;
+        int newHeight = originalBitmap.height / 2;
+
+        // Enforce minimum width and height
+        if (newWidth < 1)
+            newWidth = 1;
+        if (newHeight < 1)
+            newHeight = 1;
+
+        final TextureBitmap downScaled = new TextureBitmap(newWidth, newHeight,
+                originalBitmap.multiplicationFactor / 2d);
+
+        final int[] srcPixels = originalBitmap.pixels;
+        final int[] dstPixels = downScaled.pixels;
+        final int srcW = originalBitmap.width;
+        final int srcH = originalBitmap.height;
+        final int srcWMinus1 = srcW - 1;
+        final int srcHMinus1 = srcH - 1;
+
+        for (int y = 0; y < newHeight; y++) {
+            final int srcYBase = y * 2;
+            final int srcY1 = Math.min(srcYBase, srcHMinus1);
+            final int srcY2 = Math.min(srcYBase + 1, srcHMinus1);
+            final int row1Offset = srcY1 * srcW;
+            final int row2Offset = srcY2 * srcW;
+
+            for (int x = 0; x < newWidth; x++) {
+                final int srcXBase = x * 2;
+                final int srcX1 = Math.min(srcXBase, srcWMinus1);
+                final int srcX2 = Math.min(srcXBase + 1, srcWMinus1);
+
+                final int p0 = srcPixels[row1Offset + srcX1];
+                final int p1 = srcPixels[row1Offset + srcX2];
+                final int p2 = srcPixels[row2Offset + srcX1];
+                final int p3 = srcPixels[row2Offset + srcX2];
+
+                final int a = (((p0 >>> 24) + (p1 >>> 24) + (p2 >>> 24) + (p3 >>> 24)) >> 2);
+                final int r = ((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff) + ((p2 >> 16) & 0xff) + ((p3 >> 16) & 0xff)) >> 2);
+                final int g = ((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff) + ((p2 >> 8) & 0xff) + ((p3 >> 8) & 0xff)) >> 2);
+                final int b = (((p0 & 0xff) + (p1 & 0xff) + (p2 & 0xff) + (p3 & 0xff)) >> 2);
+
+                dstPixels[y * newWidth + x] = (a << 24) | (r << 16) | (g << 8) | b;
+            }
+        }
+
+        return downScaled;
+    }
+
+    /**
+     * Returns a downscaled bitmap at the specified mipmap level, creating it lazily if needed.
+     *
+     * <p>Level 0 is half the primary resolution, level 1 is a quarter, and so on.
+     * Each level is derived by downscaling the previous level by a factor of 2.</p>
+     *
+     * @param scaleFactor the downscale level index (0 = 1/2 size, 1 = 1/4 size, etc.)
+     * @return the cached or newly created downscaled {@link TextureBitmap}
+     * @see #downscaleBitmap(TextureBitmap)
+     */
+    public TextureBitmap getDownscaledBitmap(final int scaleFactor) {
+        if (downSampled[scaleFactor] == null) {
+
+            TextureBitmap largerBitmap;
+            if (scaleFactor == 0)
+                largerBitmap = primaryBitmap;
+            else
+                largerBitmap = getDownscaledBitmap(scaleFactor - 1);
+
+            downSampled[scaleFactor] = downscaleBitmap(largerBitmap);
+        }
+
+        return downSampled[scaleFactor];
+    }
+
+    /**
+     * Returns the bitmap that should be used for rendering at the given zoom
+     *
+     * @param scaleFactor The upscale factor
+     * @return The bitmap
+     */
+    public TextureBitmap getUpscaledBitmap(final int scaleFactor) {
+        if (upSampled[scaleFactor] == null) {
+
+            TextureBitmap smallerBitmap;
+            if (scaleFactor == 0)
+                smallerBitmap = primaryBitmap;
+            else
+                smallerBitmap = getUpscaledBitmap(scaleFactor - 1);
+
+            upSampled[scaleFactor] = upscaleBitmap(smallerBitmap);
+        }
+
+        return upSampled[scaleFactor];
+    }
+
+    /**
+     * Returns the appropriate mipmap level for rendering at the given scale.
+     *
+     * <p>Scale factor represents how large the texture appears on screen
+     * relative to its native resolution:</p>
+     * <ul>
+     *   <li>scale &lt; 1.0: texture appears smaller (use downscaled mipmap)</li>
+     *   <li>scale 1.0-2.0: texture appears near native size (use primary bitmap)</li>
+     *   <li>scale &gt; 2.0: texture appears much larger (use upscaled mipmap)</li>
+     * </ul>
+     *
+     * @param scale the apparent scale factor of the texture on screen
+     * @return the best-fit mipmap level as a {@link TextureBitmap}
+     */
+    public TextureBitmap getMipmapForScale(final double scale) {
+
+        if (scale < 1) {
+            final int mipmapLevel = getDownscaleMipmapLevel(scale);
+            return getDownscaledBitmap(mipmapLevel);
+        } else if (scale > 2) {
+            final int mipmapLevel = getUpscaleMipmapLevel(scale);
+
+            if (mipmapLevel < 0)
+                return primaryBitmap;
+
+            return getUpscaledBitmap(mipmapLevel);
+        }
+
+        return primaryBitmap;
+    }
+
+    /**
+     * Resets the cache of resampled bitmaps
+     */
+    public void resetResampledBitmapCache() {
+        fill(upSampled, null);
+
+        fill(downSampled, null);
+    }
+
+    /**
+     * Upscales the given bitmap by a factor of 2
+     *
+     * @param originalBitmap The bitmap to upscale
+     * @return The upscaled bitmap
+     */
+    public TextureBitmap upscaleBitmap(final TextureBitmap originalBitmap) {
+        final int srcW = originalBitmap.width;
+        final int srcH = originalBitmap.height;
+        final int newWidth = srcW * 2;
+        final int newHeight = srcH * 2;
+        final int srcWMinus1 = srcW - 1;
+        final int srcHMinus1 = srcH - 1;
+
+        final TextureBitmap upScaled = new TextureBitmap(newWidth, newHeight,
+                originalBitmap.multiplicationFactor * 2d);
+
+        final int[] src = originalBitmap.pixels;
+        final int[] dst = upScaled.pixels;
+
+        for (int y = 0; y < srcH; y++) {
+            final int srcRowOffset = y * srcW;
+            final int nextRowOffset = Math.min(y + 1, srcHMinus1) * srcW;
+            final int dstRow0Offset = (y * 2) * newWidth;
+            final int dstRow1Offset = (y * 2 + 1) * newWidth;
+
+            for (int x = 0; x < srcW; x++) {
+                final int nx = Math.min(x + 1, srcWMinus1);
+
+                final int p00 = src[srcRowOffset + x];
+                final int p10 = src[srcRowOffset + nx];
+                final int p01 = src[nextRowOffset + x];
+                final int p11 = src[nextRowOffset + nx];
+
+                dst[dstRow0Offset + x * 2] = p00;
+                dst[dstRow0Offset + x * 2 + 1] = avg2(p00, p10);
+                dst[dstRow1Offset + x * 2] = avg2(p00, p01);
+                dst[dstRow1Offset + x * 2 + 1] = avg4(p00, p10, p01, p11);
+            }
+        }
+
+        return upScaled;
+    }
+
+    private static int avg2(final int p0, final int p1) {
+        return (((((p0 >>> 24) + (p1 >>> 24)) >> 1) << 24)
+                | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff)) >> 1) << 16)
+                | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff)) >> 1) << 8)
+                | (((p0 & 0xff) + (p1 & 0xff)) >> 1));
+    }
+
+    private static int avg4(final int p0, final int p1, final int p2, final int p3) {
+        return ((((p0 >>> 24) + (p1 >>> 24) + (p2 >>> 24) + (p3 >>> 24)) >> 2) << 24)
+                | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff) + ((p2 >> 16) & 0xff) + ((p3 >> 16) & 0xff)) >> 2) << 16)
+                | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff) + ((p2 >> 8) & 0xff) + ((p3 >> 8) & 0xff)) >> 2) << 8)
+                | (((p0 & 0xff) + (p1 & 0xff) + (p2 & 0xff) + (p3 & 0xff)) >> 2);
+    }
+
+    /**
+     * A helper class that accumulates color values for a given area of a bitmap.
+     */
+    public static class ColorAccumulator {
+        /** Accumulated red component. */
+        public int r;
+        /** Accumulated green component. */
+        public int g;
+        /** Accumulated blue component. */
+        public int b;
+        /** Accumulated alpha component. */
+        public int a;
+
+        /** Number of pixels accumulated. */
+        public int pixelCount = 0;
+
+        /**
+         * Creates a new color accumulator with zero values.
+         */
+        public ColorAccumulator() {
+        }
+
+        /**
+         * Accumulates the color values of the given pixel
+         *
+         * @param bitmap The bitmap
+         * @param x      The x coordinate of the pixel
+         * @param y      The y coordinate of the pixel
+         */
+        public void accumulate(final TextureBitmap bitmap, final int x,
+                               final int y) {
+            final int pixel = bitmap.pixels[bitmap.getAddress(x, y)];
+            a += (pixel >> 24) & 0xff;
+            r += (pixel >> 16) & 0xff;
+            g += (pixel >> 8) & 0xff;
+            b += pixel & 0xff;
+            pixelCount++;
+        }
+
+        /**
+         * Resets the accumulator
+         */
+        public void reset() {
+            a = 0;
+            r = 0;
+            g = 0;
+            b = 0;
+            pixelCount = 0;
+        }
+
+        /**
+         * Stores the accumulated color values in the given bitmap
+         *
+         * @param bitmap The bitmap
+         * @param x      The x coordinate of the pixel
+         * @param y      The y coordinate of the pixel
+         */
+        public void storeResult(final TextureBitmap bitmap, final int x,
+                                final int y) {
+            final int avgA = a / pixelCount;
+            final int avgR = r / pixelCount;
+            final int avgG = g / pixelCount;
+            final int avgB = b / pixelCount;
+            bitmap.pixels[bitmap.getAddress(x, y)] = (avgA << 24) | (avgR << 16) | (avgG << 8) | avgB;
+        }
+    }
+
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureBitmap.java
new file mode 100644 (file)
index 0000000..8969225
--- /dev/null
@@ -0,0 +1,291 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.texture;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * Represents a single resolution level of a texture as a raw int array.
+ *
+ * <p>Each pixel is stored as a single int in ARGB format:
+ * {@code (alpha << 24) | (red << 16) | (green << 8) | blue}.
+ * This matches the {@link java.awt.image.BufferedImage#TYPE_INT_ARGB} format.</p>
+ *
+ * <p>{@code TextureBitmap} is used internally by {@link Texture} to represent
+ * individual mipmap levels. The {@link #multiplicationFactor} records the
+ * scale ratio relative to the primary (native) resolution -- for example,
+ * a value of 0.5 means this bitmap is half the original size, and 2.0
+ * means it is double.</p>
+ *
+ * <p>This class provides low-level pixel operations including:</p>
+ * <ul>
+ *   <li>Alpha-blended pixel transfer to a target raster ({@link #drawPixel(int, int[], int)})</li>
+ *   <li>Direct pixel writes using engine {@link Color} ({@link #drawPixel(int, int, Color)})</li>
+ *   <li>Filled rectangle drawing ({@link #drawRectangle(int, int, int, int, Color)})</li>
+ *   <li>Full-surface color fill ({@link #fillColor(Color)})</li>
+ * </ul>
+ *
+ * @see Texture
+ * @see Color
+ */
+public class TextureBitmap {
+
+    /**
+     * Raw pixel data in ARGB int format.
+     * Each int encodes: {@code (alpha << 24) | (red << 16) | (green << 8) | blue}.
+     * The array length is {@code width * height}.
+     */
+    public final int[] pixels;
+
+    /**
+     * The width of this bitmap in pixels.
+     */
+    public final int width;
+
+    /**
+     * The height of this bitmap in pixels.
+     */
+    public final int height;
+
+    /**
+     * The scale factor of this bitmap relative to the primary (native) texture resolution.
+     * A value of 1.0 indicates the native resolution, 0.5 indicates half-size, 2.0 indicates double-size, etc.
+     */
+    public double multiplicationFactor;
+
+/**
+     * Creates a texture bitmap backed by an existing int array.
+     *
+     * <p>This constructor is typically used when the bitmap data is obtained from
+     * a {@link java.awt.image.BufferedImage}'s raster, allowing direct access to
+     * the image's pixel data without copying.</p>
+     *
+     * @param width                the bitmap width in pixels
+     * @param height               the bitmap height in pixels
+     * @param pixels               the raw pixel data array (must be at least {@code width * height} ints)
+     * @param multiplicationFactor the scale factor relative to the native texture resolution
+     */
+    public TextureBitmap(final int width, final int height, final int[] pixels,
+                          final double multiplicationFactor) {
+
+        this.width = width;
+        this.height = height;
+        this.pixels = pixels;
+        this.multiplicationFactor = multiplicationFactor;
+    }
+
+    /**
+     * Creates a texture bitmap with a newly allocated int array.
+     *
+     * <p>The pixel data array is initialized to all zeros (fully transparent black).</p>
+     *
+     * @param width                the bitmap width in pixels
+     * @param height               the bitmap height in pixels
+     * @param multiplicationFactor the scale factor relative to the native texture resolution
+     */
+    public TextureBitmap(final int width, final int height,
+                          final double multiplicationFactor) {
+
+        this(width, height, new int[width * height], multiplicationFactor);
+    }
+
+    /**
+     * Transfer (render) one pixel from current {@link TextureBitmap} to target RGB raster.
+     *
+     * <p>This texture stores pixels in ARGB format. The target is RGB format (no alpha).
+     * Alpha blending is performed based on the source pixel's alpha value.</p>
+     *
+     * <p><b>Performance note:</b> Uses bit-shift instead of division for alpha blending,
+     * and pre-multiplies source colors to reduce per-pixel operations.</p>
+     *
+     * @param sourcePixelAddress Pixel index within current texture.
+     * @param targetBitmap       Target RGB pixel array.
+     * @param targetPixelAddress Pixel index within target image.
+     */
+    public void drawPixel(final int sourcePixelAddress,
+                          final int[] targetBitmap, final int targetPixelAddress) {
+
+        final int sourcePixel = pixels[sourcePixelAddress];
+        final int textureAlpha = (sourcePixel >> 24) & 0xff;
+
+        if (textureAlpha == 0)
+            return;
+
+        if (textureAlpha == 255) {
+            targetBitmap[targetPixelAddress] = sourcePixel;
+            return;
+        }
+
+        final int backgroundAlpha = 255 - textureAlpha;
+
+        // Pre-multiply source colors by alpha to reduce operations in blend
+        final int srcR = ((sourcePixel >> 16) & 0xff) * textureAlpha;
+        final int srcG = ((sourcePixel >> 8) & 0xff) * textureAlpha;
+        final int srcB = (sourcePixel & 0xff) * textureAlpha;
+
+        final int destPixel = targetBitmap[targetPixelAddress];
+        final int destR = (destPixel >> 16) & 0xff;
+        final int destG = (destPixel >> 8) & 0xff;
+        final int destB = destPixel & 0xff;
+
+        // Use bit-shift instead of division for faster blending
+        final int r = ((destR * backgroundAlpha) + srcR) >> 8;
+        final int g = ((destG * backgroundAlpha) + srcG) >> 8;
+        final int b = ((destB * backgroundAlpha) + srcB) >> 8;
+
+        targetBitmap[targetPixelAddress] = (r << 16) | (g << 8) | b;
+    }
+
+    /**
+     * Renders a scanline using pre-computed source pixel addresses.
+     *
+     * <p>This variant is optimized for cases where source addresses are computed
+     * externally (e.g., by a caller that already has the stepping logic).
+     * The sourceAddresses array must contain valid indices into {@link #pixels}.</p>
+     *
+     * @param sourceAddresses     array of source pixel addresses (indices into pixels array)
+     * @param targetBitmap        target RGB pixel array
+     * @param targetStartAddress  starting index in the target array
+     * @param pixelCount          number of pixels to render
+     */
+    public void drawScanlineWithAddresses(final int[] sourceAddresses,
+                                          final int[] targetBitmap, final int targetStartAddress,
+                                          final int pixelCount) {
+
+        int targetOffset = targetStartAddress;
+
+        for (int i = 0; i < pixelCount; i++) {
+            final int sourcePixel = pixels[sourceAddresses[i]];
+            final int textureAlpha = (sourcePixel >> 24) & 0xff;
+
+            if (textureAlpha == 255) {
+                targetBitmap[targetOffset] = sourcePixel;
+            } else if (textureAlpha != 0) {
+                final int backgroundAlpha = 255 - textureAlpha;
+
+                final int srcR = ((sourcePixel >> 16) & 0xff) * textureAlpha;
+                final int srcG = ((sourcePixel >> 8) & 0xff) * textureAlpha;
+                final int srcB = (sourcePixel & 0xff) * textureAlpha;
+
+                final int destPixel = targetBitmap[targetOffset];
+                final int destR = (destPixel >> 16) & 0xff;
+                final int destG = (destPixel >> 8) & 0xff;
+                final int destB = destPixel & 0xff;
+
+                final int r = ((destR * backgroundAlpha) + srcR) >> 8;
+                final int g = ((destG * backgroundAlpha) + srcG) >> 8;
+                final int b = ((destB * backgroundAlpha) + srcB) >> 8;
+
+                targetBitmap[targetOffset] = (r << 16) | (g << 8) | b;
+            }
+
+            targetOffset++;
+        }
+    }
+
+    /**
+     * Draws a single pixel at the specified coordinates using the given color.
+     *
+     * <p>The color components are written directly without alpha blending.
+     * Coordinates are clamped to the bitmap bounds by {@link #getAddress(int, int)}.</p>
+     *
+     * @param x     the x coordinate of the pixel
+     * @param y     the y coordinate of the pixel
+     * @param color the color to write
+     */
+    public void drawPixel(final int x, final int y, final Color color) {
+        pixels[getAddress(x, y)] = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b;
+    }
+
+    /**
+     * Fills a rectangular region with the specified color.
+     *
+     * <p>If {@code x1 > x2}, the coordinates are swapped to ensure correct rendering.
+     * The same applies to {@code y1} and {@code y2}. The rectangle is exclusive of the
+     * right and bottom edges.</p>
+     *
+     * <p><b>Performance:</b> Uses {@link java.util.Arrays#fill(int[], int, int, int)}
+     * per scanline for optimal JVM-optimized memory writes.</p>
+     *
+     * @param x1    the left x coordinate
+     * @param y1    the top y coordinate
+     * @param x2    the right x coordinate (exclusive)
+     * @param y2    the bottom y coordinate (exclusive)
+     * @param color the fill color
+     */
+    public void drawRectangle(int x1, int y1, int x2, int y2,
+                              final Color color) {
+
+        if (x1 > x2) {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+        }
+
+        if (y1 > y2) {
+            final int tmp = y1;
+            y1 = y2;
+            y2 = tmp;
+        }
+
+        // Clamp to bitmap bounds
+        if (x1 < 0) x1 = 0;
+        if (y1 < 0) y1 = 0;
+        if (x2 > width) x2 = width;
+        if (y2 > height) y2 = height;
+
+        final int pixel = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b;
+        final int rowWidth = x2 - x1;
+
+        if (rowWidth <= 0)
+            return;
+
+        // Fill each scanline using Arrays.fill for optimal performance
+        for (int y = y1; y < y2; y++) {
+            final int rowStart = y * width + x1;
+            java.util.Arrays.fill(pixels, rowStart, rowStart + rowWidth, pixel);
+        }
+    }
+
+    /**
+     * Fills the entire bitmap with the specified color.
+     *
+     * <p>Every pixel in the bitmap is set to the given color value,
+     * overwriting all existing content.</p>
+     *
+     * @param color the color to fill the entire bitmap with
+     */
+    public void fillColor(final Color color) {
+        final int pixel = (color.a << 24) | (color.r << 16) | (color.g << 8) | color.b;
+        java.util.Arrays.fill(pixels, pixel);
+    }
+
+    /**
+     * Computes the index into the {@link #pixels} array for the pixel at ({@code x}, {@code y}).
+     *
+     * <p>Coordinates are clamped to the valid range {@code [0, width-1]} and
+     * {@code [0, height-1]} so that out-of-bounds accesses are safely handled
+     * by sampling the nearest edge pixel.</p>
+     *
+     * @param x the x coordinate of the pixel
+     * @param y the y coordinate of the pixel
+     * @return the index into the pixels array for the specified pixel
+     */
+    public int getAddress(int x, int y) {
+        if (x < 0)
+            x = 0;
+
+        if (x >= width)
+            x = width - 1;
+
+        if (y < 0)
+            y = 0;
+
+        if (y >= height)
+            y = height - 1;
+
+        return (y * width) + x;
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/TextureGenerator.java
new file mode 100644 (file)
index 0000000..98ef154
--- /dev/null
@@ -0,0 +1,327 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.texture;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+import java.lang.ref.WeakReference;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.lang.Math.pow;
+import static java.lang.Math.sqrt;
+
+/**
+ * Factory class for generating reusable textures with configurable borders and glow effects.
+ *
+ * <p>Provides static factory methods to create common texture patterns:</p>
+ * <ul>
+ *   <li>{@link #solidWithBorder} - solid fill color with opaque border (for bordered polygons)</li>
+ *   <li>{@link #glowingBorder} - transparent center with glowing edges (for wireframe-effect shapes)</li>
+ *   <li>{@link #radialGlow} - circular radial gradient (for point/billboard glows)</li>
+ * </ul>
+ *
+ * <p><b>Texture caching:</b> Textures are cached by their generation parameters using a
+ * {@link WeakReference}-based cache. When textures are no longer referenced elsewhere,
+ * they are automatically garbage collected. This reduces memory usage when many shapes
+ * share identical textures.</p>
+ *
+ * <p><b>Example usage:</b></p>
+ * <pre>{@code
+ * // RGB cube plate with black border
+ * Texture tex1 = TextureGenerator.solidWithBorder(64, new Color(255, 0, 0), new Color(0, 0, 0), 3);
+ *
+ * // Wireframe-effect texture (glowing cyan edges, transparent center)
+ * Texture tex2 = TextureGenerator.glowingBorder(64, new Color(0, 255, 255), 6, 120, true);
+ *
+ * // Circular glow point
+ * Texture tex3 = TextureGenerator.radialGlow(100, new Color(255, 200, 100));
+ * }</pre>
+ *
+ * @see Texture
+ * @see Color
+ */
+public final class TextureGenerator {
+
+    /**
+     * Cache of generated textures, keyed by configuration parameters.
+     * Uses WeakReference so textures are GC'd when no longer referenced elsewhere.
+     */
+    private static final Map<TextureKey, WeakReference<Texture>> textureCache = new HashMap<>();
+
+    /**
+     * Private constructor to prevent instantiation.
+     * This class only provides static factory methods.
+     */
+    private TextureGenerator() {
+    }
+
+    /**
+     * Creates a texture with a solid fill color and an opaque border.
+     *
+     * <p>The fill color fills the entire texture except for the border region.
+     * The border is drawn as an opaque rectangle inset from the edges.</p>
+     *
+     * <p><b>Caching:</b> Identical parameters produce the same cached texture instance.</p>
+     *
+     * @param size        the texture width and height in pixels
+     * @param fillColor   the color filling the interior
+     * @param borderColor the color of the border
+     * @param borderWidth the width of the border in pixels
+     * @param maxUpscale  the maximum number of upscaled mipmap levels (0 = no upscale, 1 = 2x upscale, 2 = 4x upscale)
+     * @return a texture with solid fill and opaque border
+     */
+    public static Texture solidWithBorder(final int size, final Color fillColor,
+                                          final Color borderColor, final int borderWidth,
+                                          final int maxUpscale) {
+        final TextureKey key = new TextureKey(size, fillColor, borderColor, borderWidth,
+                TextureType.SOLID_WITH_BORDER, 0, false, maxUpscale);
+
+        return getOrCreate(key, () -> generateSolidWithBorder(size, fillColor, borderColor, borderWidth, maxUpscale));
+    }
+
+    /**
+     * Creates a texture with a transparent center and glowing border edges.
+     *
+     * <p>This texture is useful for creating wireframe-effect shapes: the polygon
+     * appears to have only edges, with the interior being transparent. The border
+     * has a glow effect where intensity decreases from the edge toward the center.</p>
+     *
+     * <p><b>Glow effect:</b> Multiple concentric border lines are drawn with decreasing
+     * alpha values, creating a luminous edge appearance. The glow intensity controls
+     * how bright the innermost edge appears.</p>
+     *
+     * <p><b>Caching:</b> Identical parameters produce the same cached texture instance.</p>
+     *
+     * @param size          the texture width and height in pixels
+     * @param borderColor   the color of the glowing border
+     * @param borderWidth   the width of the border region in pixels (where glow appears)
+     * @param glowIntensity the base intensity added to the border color (0-255 range)
+     * @param transparent   if true, center is fully transparent; if false, has slight tint
+     * @param maxUpscale    the maximum number of upscaled mipmap levels (0 = no upscale, 1 = 2x upscale, 2 = 4x upscale)
+     * @return a texture with glowing edges and transparent center
+     */
+    public static Texture glowingBorder(final int size, final Color borderColor,
+                                         final int borderWidth, final int glowIntensity,
+                                         final boolean transparent, final int maxUpscale) {
+        final TextureKey key = new TextureKey(size, borderColor, Color.TRANSPARENT, borderWidth,
+                TextureType.GLOWING_BORDER, glowIntensity, transparent, maxUpscale);
+
+        return getOrCreate(key, () -> generateGlowingBorder(size, borderColor, borderWidth,
+                glowIntensity, transparent, maxUpscale));
+    }
+
+    /**
+     * Creates a texture with a circular radial gradient glow.
+     *
+     * <p>The texture has a circular alpha gradient: fully opaque at the center,
+     * transitioning to fully transparent at the edges. The color intensity
+     * remains constant while alpha decreases radially.</p>
+     *
+     * <p>This is suitable for rendering glowing points or circular billboards.
+     * The center of the texture is the brightest point, fading outward.</p>
+     *
+     * <p><b>Caching:</b> Identical parameters produce the same cached texture instance.</p>
+     *
+     * @param size  the texture width and height in pixels (should be even)
+     * @param color the color of the glow (alpha is overridden by radial gradient)
+     * @return a texture with circular radial alpha gradient
+     */
+    public static Texture radialGlow(final int size, final Color color) {
+        final TextureKey key = new TextureKey(size, color, Color.TRANSPARENT, 0,
+                TextureType.RADIAL_GLOW, 0, false, 1);
+
+        return getOrCreate(key, () -> generateRadialGlow(size, color));
+    }
+
+    /**
+     * Retrieves a cached texture or creates a new one if not cached.
+     *
+     * @param key      the cache key identifying the texture configuration
+     * @param generator the function to create the texture if not cached
+     * @return the cached or newly created texture
+     */
+    private static Texture getOrCreate(final TextureKey key, final TextureSupplier generator) {
+        synchronized (textureCache) {
+            final WeakReference<Texture> ref = textureCache.get(key);
+            if (ref != null) {
+                final Texture cached = ref.get();
+                if (cached != null) {
+                    return cached;
+                }
+                // Reference was cleared, remove stale entry
+                textureCache.remove(key);
+            }
+
+            final Texture texture = generator.create();
+            textureCache.put(key, new WeakReference<>(texture));
+            return texture;
+        }
+    }
+
+    /**
+     * Generates a texture with solid fill and opaque border.
+     */
+    private static Texture generateSolidWithBorder(final int size, final Color fillColor,
+                                                   final Color borderColor, final int borderWidth,
+                                                   final int maxUpscale) {
+        final Texture texture = new Texture(size, size, maxUpscale);
+
+        // Fill interior with fill color
+        texture.primaryBitmap.drawRectangle(borderWidth, borderWidth,
+                size - borderWidth, size - borderWidth, fillColor);
+
+        // Draw border regions (top, bottom, left, right edges)
+        // Top border
+        texture.primaryBitmap.drawRectangle(0, 0, size, borderWidth, borderColor);
+        // Bottom border
+        texture.primaryBitmap.drawRectangle(0, size - borderWidth, size, size, borderColor);
+        // Left border (excluding top/bottom corners already filled)
+        texture.primaryBitmap.drawRectangle(0, borderWidth, borderWidth, size - borderWidth, borderColor);
+        // Right border
+        texture.primaryBitmap.drawRectangle(size - borderWidth, borderWidth, size, size - borderWidth, borderColor);
+
+        texture.resetResampledBitmapCache();
+        return texture;
+    }
+
+    /**
+     * Generates a texture with glowing border and transparent center.
+     */
+    private static Texture generateGlowingBorder(final int size, final Color borderColor,
+                                                 final int borderWidth, final int glowIntensity,
+                                                 final boolean transparent, final int maxUpscale) {
+        final Texture texture = new Texture(size, size, maxUpscale);
+
+        // Clear to transparent or slight tint
+        final int centerAlpha = transparent ? 0 : 30;
+        final java.awt.Color bgColor = new java.awt.Color(borderColor.r, borderColor.g, borderColor.b, centerAlpha);
+        texture.graphics.setBackground(bgColor);
+        texture.graphics.clearRect(0, 0, size, size);
+
+        // Draw concentric glow lines from outer to inner
+        for (int i = 0; i < borderWidth; i++) {
+            final int intensity = (int) (glowIntensity * (borderWidth - i) / borderWidth);
+            final int alpha = Math.max(0, 200 - i * 30);
+
+            final java.awt.Color glowColor = new java.awt.Color(
+                    Math.min(255, borderColor.r + intensity),
+                    Math.min(255, borderColor.g + intensity),
+                    Math.min(255, borderColor.b + intensity),
+                    alpha
+            );
+
+            texture.graphics.setColor(glowColor);
+            texture.graphics.drawRect(i, i, size - 1 - 2 * i, size - 1 - 2 * i);
+        }
+
+        texture.graphics.dispose();
+        texture.resetResampledBitmapCache();
+        return texture;
+    }
+
+    /**
+     * Generates a texture with circular radial alpha gradient.
+     */
+    private static Texture generateRadialGlow(final int size, final Color color) {
+        final Texture texture = new Texture(size, size, 1);
+        final int halfSize = size / 2;
+
+        for (int x = 0; x < size; x++) {
+            for (int y = 0; y < size; y++) {
+                final int distanceFromCenter = (int) sqrt(pow(halfSize - x, 2) + pow(halfSize - y, 2));
+
+                int alpha = 255 - ((270 * distanceFromCenter) / halfSize);
+                if (alpha < 0) {
+                    alpha = 0;
+                }
+
+                texture.primaryBitmap.pixels[texture.primaryBitmap.getAddress(x, y)] =
+                        (alpha << 24) | (color.r << 16) | (color.g << 8) | color.b;
+            }
+        }
+
+        texture.resetResampledBitmapCache();
+        return texture;
+    }
+
+    /**
+     * Functional interface for texture creation.
+     */
+    @FunctionalInterface
+    private interface TextureSupplier {
+        Texture create();
+    }
+
+    /**
+     * Enumeration of texture generation types for cache key differentiation.
+     */
+    private enum TextureType {
+        SOLID_WITH_BORDER,
+        GLOWING_BORDER,
+        RADIAL_GLOW
+    }
+
+    /**
+     * Cache key for texture lookup based on generation parameters.
+     *
+     * <p>Two textures with identical parameters should produce the same key,
+     * enabling cache reuse. The key includes all parameters that affect
+     * the visual appearance of the generated texture.</p>
+     */
+    private static final class TextureKey {
+        private final int size;
+        private final Color primaryColor;
+        private final Color secondaryColor;
+        private final int borderWidth;
+        private final TextureType type;
+        private final int glowIntensity;
+        private final boolean transparent;
+        private final int maxUpscale;
+
+        TextureKey(final int size, final Color primaryColor, final Color secondaryColor,
+                   final int borderWidth, final TextureType type, final int glowIntensity,
+                   final boolean transparent, final int maxUpscale) {
+            this.size = size;
+            this.primaryColor = primaryColor;
+            this.secondaryColor = secondaryColor;
+            this.borderWidth = borderWidth;
+            this.type = type;
+            this.glowIntensity = glowIntensity;
+            this.transparent = transparent;
+            this.maxUpscale = maxUpscale;
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+
+            final TextureKey that = (TextureKey) o;
+
+            if (size != that.size) return false;
+            if (borderWidth != that.borderWidth) return false;
+            if (glowIntensity != that.glowIntensity) return false;
+            if (transparent != that.transparent) return false;
+            if (maxUpscale != that.maxUpscale) return false;
+            if (type != that.type) return false;
+            if (!primaryColor.equals(that.primaryColor)) return false;
+            return secondaryColor.equals(that.secondaryColor);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = size;
+            result = 31 * result + primaryColor.hashCode();
+            result = 31 * result + secondaryColor.hashCode();
+            result = 31 * result + borderWidth;
+            result = 31 * result + type.hashCode();
+            result = 31 * result + glowIntensity;
+            result = 31 * result + (transparent ? 1 : 0);
+            result = 31 * result + maxUpscale;
+            return result;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/texture/package-info.java
new file mode 100644 (file)
index 0000000..d319ae7
--- /dev/null
@@ -0,0 +1,22 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Texture support with mipmap chains for level-of-detail rendering.
+ *
+ * <p>Textures provide 2D image data that can be mapped onto polygons. The mipmap
+ * system automatically generates scaled versions for efficient rendering at
+ * various distances.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture} - Main texture class with mipmap support</li>
+ *   <li>{@link eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap} - Raw pixel data for a single mipmap level</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture
+ */
+
+package eu.svjatoslav.aukio.e3d.renderer.raster.texture;
\ No newline at end of file
diff --git a/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png b/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png
new file mode 100644 (file)
index 0000000..47a1638
Binary files /dev/null and b/src/main/resources/eu/svjatoslav/aukio/e3d/examples/hourglass.png differ
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/TextLineTest.java
new file mode 100644 (file)
index 0000000..593fc41
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * Aukio - System for data storage, computation, exploration and interaction.
+ * Author: Svjatoslav Agejenko. 
+ * This project is released under Creative Commons Zero (CC0) license.
+ *
+*/
+
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class TextLineTest {
+
+    @Test
+    public void testAddIndent() {
+        TextLine textLine = new TextLine("test");
+        textLine.addIndent(4);
+        assertEquals("    test", textLine.toString());
+
+        textLine = new TextLine();
+        textLine.addIndent(4);
+        assertEquals("", textLine.toString());
+    }
+
+    @Test
+    public void testCutFromBeginning() {
+        TextLine textLine = new TextLine("test");
+        textLine.cutFromBeginning(2);
+        assertEquals("st", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.cutFromBeginning(4);
+        assertEquals("", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.cutFromBeginning(5);
+        assertEquals("", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.cutFromBeginning(100);
+        assertEquals("", textLine.toString());
+    }
+
+    @Test
+    public void testCutSubString() {
+        TextLine textLine = new TextLine("test");
+        assertEquals("es", textLine.cutSubString(1, 3));
+        assertEquals("tt", textLine.toString());
+
+        textLine = new TextLine("test");
+        assertEquals("st ", textLine.cutSubString(2, 5));
+        assertEquals("te", textLine.toString());
+    }
+
+    @Test
+    public void testGetCharForLocation() {
+        final TextLine textLine = new TextLine("test");
+        assertEquals('s', textLine.getCharForLocation(2));
+        assertEquals('t', textLine.getCharForLocation(3));
+        assertEquals(' ', textLine.getCharForLocation(4));
+    }
+
+    @Test
+    public void testGetIndent() {
+        final TextLine textLine = new TextLine("   test");
+        assertEquals(3, textLine.getIndent());
+    }
+
+    @Test
+    public void testGetLength() {
+        final TextLine textLine = new TextLine("test");
+        assertEquals(4, textLine.getLength());
+    }
+
+    @Test
+    public void testInsertCharacter() {
+        TextLine textLine = new TextLine("test");
+        textLine.insertCharacter(1, 'o');
+        assertEquals("toest", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.insertCharacter(5, 'o');
+        assertEquals("test o", textLine.toString());
+
+    }
+
+    @Test
+    public void testIsEmpty() {
+        TextLine textLine = new TextLine("");
+        assertEquals(true, textLine.isEmpty());
+
+        textLine = new TextLine("     ");
+        assertEquals(true, textLine.isEmpty());
+
+        textLine = new TextLine("l");
+        assertEquals(false, textLine.isEmpty());
+    }
+
+    @Test
+    public void testRemoveCharacter() {
+        TextLine textLine = new TextLine("test");
+        textLine.removeCharacter(0);
+        assertEquals("est", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.removeCharacter(3);
+        assertEquals("tes", textLine.toString());
+
+        textLine = new TextLine("test");
+        textLine.removeCharacter(4);
+        assertEquals("test", textLine.toString());
+    }
+
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java b/src/test/java/eu/svjatoslav/aukio/e3d/gui/textEditorComponent/package-info.java
new file mode 100644 (file)
index 0000000..dfcdecd
--- /dev/null
@@ -0,0 +1,13 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Unit tests for the text editor component.
+ *
+ * <p>Tests for {@link eu.svjatoslav.aukio.e3d.gui.textEditorComponent.TextLine}
+ * and related text processing functionality.</p>
+ */
+
+package eu.svjatoslav.aukio.e3d.gui.textEditorComponent;
\ No newline at end of file
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/headless/HeadlessToolkitTest.java
new file mode 100644 (file)
index 0000000..c648bdd
--- /dev/null
@@ -0,0 +1,85 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.headless;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.Camera;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import org.junit.Test;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Verifies the headless toolkit: pose parsing round-trip, snapshot
+ * rendering actually paints, pixel assertions agree with the render,
+ * golden comparison passes/fails deterministically.
+ */
+public class HeadlessToolkitTest {
+
+    /** A red triangle 100 units in front of the origin-facing camera. */
+    private static ShapeCollection triangleScene() {
+        final ShapeCollection scene = new ShapeCollection();
+        scene.addShape(new SolidPolygon(
+                new Point3D(-100, 0, 100), new Point3D(100, 0, 100),
+                new Point3D(0, 100, 100), Color.RED));
+        return scene;
+    }
+
+    @Test
+    public void poseRoundTrip() {
+        final Camera camera = Snapshot.cameraFromPose("290.31, -35.59, -2.10, -0.58, -0.15, -0.00");
+        final String pose = Snapshot.poseString(camera);
+        // parsed back, the pose string must be identical (same 2-decimal precision)
+        assertEquals("290.31, -35.59, -2.10, -0.58, -0.15, -0.00", pose);
+    }
+
+    @Test
+    public void renderPaintsTriangle() {
+        final BufferedImage image = Snapshot.render(triangleScene(), null,
+                "0, 0, 0, 0, 0, 0", 320, 240);
+        final long red = PixelAssertions.countColor(image, 0xFF0000);
+        assertTrue("red triangle should paint, redPixels=" + red, red > 5000);
+        assertTrue("most of the frame stays background",
+                PixelAssertions.unpaintedFraction(image, 0) > 0.5);
+    }
+
+    @Test
+    public void unpaintedFractionDetectsRegion() {
+        final BufferedImage image = Snapshot.render(triangleScene(), null,
+                "0, 0, 0, 0, 0, 0", 320, 240);
+        // the triangle is centered; corners must be unpainted
+        final double cornerBand = PixelAssertions.unpaintedFraction(image, 0, 0, 0, 0.1, 0.1);
+        assertEquals(1.0, cornerBand, 0.001);
+    }
+
+    @Test
+    public void goldenCompareExact() throws Exception {
+        final BufferedImage image = Snapshot.render(triangleScene(), null,
+                "0, 0, 0, 0, 0, 0", 320, 240);
+        final File golden = File.createTempFile("golden", ".png");
+        golden.deleteOnExit();
+        Snapshot.save(image, golden.getAbsolutePath());
+
+        final GoldenImage.Result exact = GoldenImage.compare(image, golden, 0, 0.0);
+        assertTrue(exact.toString(), exact.passed);
+        assertEquals(0, exact.diffPixels);
+
+        // paint one pixel differently: comparison must now fail at tolerance 0
+        image.setRGB(10, 10, 0x00FF00);
+        final GoldenImage.Result drifted = GoldenImage.compare(image, golden, 0, 0.0);
+        assertTrue(!drifted.passed);
+        assertEquals(1, drifted.diffPixels);
+
+        // ... but pass when one differing pixel is within the allowed fraction
+        final GoldenImage.Result tolerated = GoldenImage.compare(image, golden, 0, 0.001);
+        assertTrue(tolerated.toString(), tolerated.passed);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/QuaternionTest.java
new file mode 100644 (file)
index 0000000..8745219
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class QuaternionTest {
+
+    @Test
+    public void testFromAnglesProducesValidMatrix() {
+        final Quaternion quaternion = Quaternion.fromAngles(0.5, 0.3);
+        final Matrix3x3 matrix = quaternion.toMatrix();
+
+        // Verify matrix is a valid rotation (determinant ≈ 1)
+        final double det = matrix.m00 * (matrix.m11 * matrix.m22 - matrix.m12 * matrix.m21)
+                         - matrix.m01 * (matrix.m10 * matrix.m22 - matrix.m12 * matrix.m20)
+                         + matrix.m02 * (matrix.m10 * matrix.m21 - matrix.m11 * matrix.m20);
+        assertEquals(1.0, det, 0.0001);
+    }
+
+    @Test
+    public void testToMatrixAliasesToMatrix3x3() {
+        final Quaternion quaternion = Quaternion.fromAngles(0.7, -0.4);
+        final Matrix3x3 m1 = quaternion.toMatrix();
+        final Matrix3x3 m2 = quaternion.toMatrix3x3();
+
+        final double epsilon = 0.0001;
+        assertEquals(m1.m00, m2.m00, epsilon);
+        assertEquals(m1.m01, m2.m01, epsilon);
+        assertEquals(m1.m02, m2.m02, epsilon);
+        assertEquals(m1.m10, m2.m10, epsilon);
+        assertEquals(m1.m11, m2.m11, epsilon);
+        assertEquals(m1.m12, m2.m12, epsilon);
+        assertEquals(m1.m20, m2.m20, epsilon);
+        assertEquals(m1.m21, m2.m21, epsilon);
+        assertEquals(m1.m22, m2.m22, epsilon);
+    }
+
+    @Test
+    public void testCloneProducesIndependentCopy() {
+        final Quaternion original = Quaternion.fromAngles(0.5, 0.3);
+        final Quaternion clone = original.clone();
+
+        assertEquals(original.w, clone.w, 0.0001);
+        assertEquals(original.x, clone.x, 0.0001);
+        assertEquals(original.y, clone.y, 0.0001);
+        assertEquals(original.z, clone.z, 0.0001);
+
+        // Modify original, verify clone is unaffected
+        final double originalW = original.w;
+        original.w = 0;
+        assertEquals(originalW, clone.w, 0.0001);
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java
new file mode 100644 (file)
index 0000000..ed5275b
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.math;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import org.junit.Test;
+
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+
+public class TransformStackTest {
+
+    private static final double EPSILON = 1e-6;
+
+    @Test
+    public void transformWithEmptyStackIsIdentity() {
+        final TransformStack stack = new TransformStack();
+        final Point3D p = new Point3D(10, 20, 30);
+        final Point3D result = new Point3D();
+
+        stack.transform(p, result);
+
+        assertEquals(p.x, result.x, EPSILON);
+        assertEquals(p.y, result.y, EPSILON);
+        assertEquals(p.z, result.z, EPSILON);
+    }
+
+    @Test
+    public void transformMatchesSequentialApplication() {
+        final Random rnd = new Random(42);
+
+        for (int depth = 1; depth <= 8; depth++) {
+            final Transform[] chain = new Transform[depth];
+            final TransformStack stack = new TransformStack();
+            for (int i = 0; i < depth; i++) {
+                chain[i] = Transform.fromAngles(
+                        (rnd.nextDouble() - 0.5) * 1000,
+                        (rnd.nextDouble() - 0.5) * 1000,
+                        (rnd.nextDouble() - 0.5) * 1000,
+                        (rnd.nextDouble() - 0.5) * Math.PI * 2,
+                        (rnd.nextDouble() - 0.5) * Math.PI,
+                        (rnd.nextDouble() - 0.5) * Math.PI);
+                stack.addTransform(chain[i]);
+            }
+
+            for (int k = 0; k < 100; k++) {
+                final Point3D p = new Point3D(
+                        (rnd.nextDouble() - 0.5) * 2000,
+                        (rnd.nextDouble() - 0.5) * 2000,
+                        (rnd.nextDouble() - 0.5) * 2000);
+
+                // Oracle: documented semantics — transforms applied in reverse
+                // order of insertion (last added = first applied)
+                final Point3D expected = new Point3D(p);
+                for (int i = depth - 1; i >= 0; i--) {
+                    chain[i].transform(expected);
+                }
+
+                final Point3D result = new Point3D();
+                stack.transform(p, result);
+
+                assertEquals("depth " + depth + " x", expected.x, result.x, EPSILON);
+                assertEquals("depth " + depth + " y", expected.y, result.y, EPSILON);
+                assertEquals("depth " + depth + " z", expected.z, result.z, EPSILON);
+            }
+        }
+    }
+
+    @Test
+    public void dropTransformRestoresParentState() {
+        final Transform a = Transform.fromAngles(100, 0, 0, 0.3, 0.1, 0);
+        final Transform b = Transform.fromAngles(0, 50, 0, 0, 0.5, 0.2);
+        final Transform c = Transform.fromAngles(0, 0, 500, 1.0, 0, 0.4);
+
+        final TransformStack stack = new TransformStack();
+        stack.addTransform(a);
+        stack.addTransform(b);
+        stack.dropTransform();
+        stack.addTransform(c);
+
+        final TransformStack reference = new TransformStack();
+        reference.addTransform(a);
+        reference.addTransform(c);
+
+        final Point3D p = new Point3D(7, -13, 42);
+        final Point3D result = new Point3D();
+        final Point3D expected = new Point3D();
+        stack.transform(p, result);
+        reference.transform(p, expected);
+
+        assertEquals(expected.x, result.x, EPSILON);
+        assertEquals(expected.y, result.y, EPSILON);
+        assertEquals(expected.z, result.z, EPSILON);
+    }
+
+    @Test
+    public void transformComposesEagerlyAtPushTime() {
+        final Transform transform = Transform.fromAngles(10, 20, 30, 0.5, 0.2, 0.1);
+
+        final TransformStack stack = new TransformStack();
+        stack.addTransform(transform);
+
+        // Expected result uses the values the transform had when pushed
+        final Point3D p = new Point3D(1, 2, 3);
+        final Point3D expected = new Point3D(p);
+        final Transform snapshot = transform.clone();
+        snapshot.transform(expected);
+
+        // Mutating the transform after pushing must NOT affect the stack:
+        // composition is an eager snapshot taken at push time
+        transform.set(-999, 888, -777, 2.5, -1.5, 0.9);
+
+        final Point3D result = new Point3D();
+        stack.transform(p, result);
+
+        assertEquals(expected.x, result.x, EPSILON);
+        assertEquals(expected.y, result.y, EPSILON);
+        assertEquals(expected.z, result.z, EPSILON);
+    }
+
+    @Test
+    public void clearResetsStackToIdentity() {
+        final TransformStack stack = new TransformStack();
+        stack.addTransform(Transform.fromAngles(1, 2, 3, 0.5, 0.2, 0.1));
+        stack.addTransform(Transform.fromAngles(4, 5, 6, 0.1, 0.9, 0.3));
+        stack.clear();
+
+        final Point3D p = new Point3D(10, 20, 30);
+        final Point3D result = new Point3D();
+        stack.transform(p, result);
+
+        assertEquals(p.x, result.x, EPSILON);
+        assertEquals(p.y, result.y, EPSILON);
+        assertEquals(p.z, result.z, EPSILON);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/ParallelTransformTest.java
new file mode 100644 (file)
index 0000000..0d6fea0
--- /dev/null
@@ -0,0 +1,286 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import eu.svjatoslav.aukio.e3d.math.TransformStack;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Verifies that the parallel transform+sort pipeline produces exactly the
+ * same render queue as the serial pipeline: same shapes, same order, same
+ * culling decisions.
+ */
+public class ParallelTransformTest {
+
+    private static final int W = 1280, H = 720;
+    private static final double EPSILON = 1e-9;
+
+    private ExecutorService executor;
+
+    @After
+    public void tearDown() {
+        if (executor != null) {
+            executor.shutdownNow();
+        }
+    }
+
+    private ShapeCollection buildScene(final ViewPanel panel) {
+        final ShapeCollection scene = panel.getRootShapeCollection();
+        panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600));
+
+        // Top-level spheres: each expands to hundreds of triangles internally
+        for (int i = 0; i < 40; i++) {
+            scene.addShape(new SolidPolygonSphere(
+                    new Point3D((i % 8 - 4) * 60, (i / 8 - 2) * 60, 400),
+                    25, 12, Color.GREEN));
+        }
+
+        // Many cheap wireframe cubes -> enough top-level items to fork on
+        final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN);
+        for (int i = 0; i < 200; i++) {
+            scene.addShape(new WireframeCube(
+                    new Point3D((i % 20 - 10) * 40, (i / 20 - 5) * 40, 700),
+                    15, appearance));
+        }
+
+        // Nested composite with its own transform -> transform stacking
+        final AbstractCompositeShape nested = new AbstractCompositeShape(new Point3D(50, -50, 500));
+        nested.setTransform(Transform.fromAngles(50, -50, 500, 0.3, 0.2, 0.1));
+        nested.addShape(new SolidPolygonCube(new Point3D(0, 0, 0), 30, Color.RED));
+        nested.addShape(new SolidPolygonSphere(new Point3D(80, 0, 40), 20, 10, Color.BLUE));
+        scene.addShape(nested);
+
+        // Composite entirely behind the camera -> frustum culling inside workers
+        final AbstractCompositeShape offscreen = new AbstractCompositeShape(new Point3D(0, 0, -5000));
+        offscreen.addShape(new SolidPolygonCube(new Point3D(0, 0, 0), 30, Color.RED));
+        scene.addShape(offscreen);
+
+        // Quad -> N-gon triangulation path
+        scene.addShape(SolidPolygon.quad(
+                new Point3D(-100, -100, 300), new Point3D(100, -100, 300),
+                new Point3D(100, 100, 300), new Point3D(-100, 100, 300),
+                Color.WHITE));
+
+        return scene;
+    }
+
+    private int[] snapshotIds(final ShapeCollection scene) {
+        final List<AbstractCoordinateShape> queue = scene.getQueuedShapes();
+        final int[] ids = new int[queue.size()];
+        for (int i = 0; i < ids.length; i++) {
+            ids[i] = queue.get(i).shapeId;
+        }
+        return ids;
+    }
+
+    private double[] snapshotZs(final ShapeCollection scene) {
+        final List<AbstractCoordinateShape> queue = scene.getQueuedShapes();
+        final double[] zs = new double[queue.size()];
+        for (int i = 0; i < zs.length; i++) {
+            zs[i] = queue.get(i).getZ(0);
+        }
+        return zs;
+    }
+
+    @Test
+    public void parallelTransformMatchesSerialPipeline() {
+        System.setProperty("java.awt.headless", "true");
+        final ViewPanel panel = new ViewPanel();
+        final ShapeCollection scene = buildScene(panel);
+
+        // Serial run: no executor -> serial fallback path
+        final RenderingContext serialCtx = new RenderingContext(W, H, 1);
+        serialCtx.prepareForNewFrameRendering();
+        scene.transformShapes(panel, serialCtx);
+        scene.sortShapes();
+        final int[] serialIds = snapshotIds(scene);
+        final double[] serialZs = snapshotZs(scene);
+        final int serialTotal = serialCtx.cullingStatistics.totalComposites.get();
+        final int serialCulled = serialCtx.cullingStatistics.culledComposites.get();
+
+        // Parallel run: executor set -> forked traversal
+        executor = Executors.newFixedThreadPool(8);
+        final RenderingContext parallelCtx = new RenderingContext(W, H, 1);
+        parallelCtx.transformExecutor = executor;
+        parallelCtx.prepareForNewFrameRendering();
+        parallelCtx.prepareForNewFrameRendering(); // distinct frameNumber -> full re-transform
+        scene.transformShapes(panel, parallelCtx);
+        scene.sortShapes();
+        final int[] parallelIds = snapshotIds(scene);
+        final double[] parallelZs = snapshotZs(scene);
+
+        // Same render queue, same order (sort is deterministic: Z then shapeId)
+        assertEquals("queued shape count", serialIds.length, parallelIds.length);
+        assertTrue("scene must be big enough to exercise parallel paths",
+                serialIds.length > 8192);
+        for (int i = 0; i < serialIds.length; i++) {
+            assertEquals("shapeId at position " + i, serialIds[i], parallelIds[i]);
+            assertEquals("Z at position " + i, serialZs[i], parallelZs[i], EPSILON);
+        }
+
+        // Same culling decisions (and thread-safe counters)
+        assertEquals(serialTotal, parallelCtx.cullingStatistics.totalComposites.get());
+        assertEquals(serialCulled, parallelCtx.cullingStatistics.culledComposites.get());
+        assertTrue("offscreen composite must be culled", serialCulled >= 1);
+
+        // Sortedness property: Z descending, shapeId ascending tiebreak.
+        // Tiebreak comparison must be exact, matching the comparator's
+        // double semantics — nearly-equal Z values are NOT a tie.
+        for (int i = 1; i < parallelIds.length; i++) {
+            assertTrue("Z order at " + i, parallelZs[i - 1] >= parallelZs[i]);
+            if (parallelZs[i - 1] == parallelZs[i]) {
+                assertTrue("shapeId tiebreak at " + i, parallelIds[i - 1] < parallelIds[i]);
+            }
+        }
+    }
+
+    @Test
+    public void nestedHeavyCompositeForksInternally() {
+        System.setProperty("java.awt.headless", "true");
+        final ViewPanel panel = new ViewPanel();
+        final ShapeCollection scene = panel.getRootShapeCollection();
+        panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600));
+
+        // Few root children: root stays below the parallel threshold and
+        // transforms serially, so any fork must come from the nested level
+        scene.addShape(new SolidPolygonSphere(new Point3D(-200, 0, 400), 25, 10, Color.GREEN));
+        scene.addShape(new SolidPolygonSphere(new Point3D(200, 0, 400), 25, 10, Color.RED));
+
+        // One outsized nested composite, well above the fork threshold
+        final AbstractCompositeShape giant = new AbstractCompositeShape(new Point3D(0, 0, 300));
+        final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN);
+        for (int i = 0; i < 200; i++) {
+            giant.addShape(new WireframeCube(
+                    new Point3D((i % 20 - 10) * 40, (i / 20 - 5) * 40, 200),
+                    15, appearance));
+        }
+        scene.addShape(giant);
+
+        // Serial run
+        final RenderingContext serialCtx = new RenderingContext(W, H, 1);
+        serialCtx.prepareForNewFrameRendering();
+        scene.transformShapes(panel, serialCtx);
+        scene.sortShapes();
+        final int[] serialIds = snapshotIds(scene);
+        final double[] serialZs = snapshotZs(scene);
+
+        // Parallel run
+        executor = Executors.newFixedThreadPool(8);
+        final RenderingContext parallelCtx = new RenderingContext(W, H, 1);
+        parallelCtx.transformExecutor = executor;
+        parallelCtx.prepareForNewFrameRendering();
+        parallelCtx.prepareForNewFrameRendering();
+        scene.transformShapes(panel, parallelCtx);
+        scene.sortShapes();
+        final int[] parallelIds = snapshotIds(scene);
+        final double[] parallelZs = snapshotZs(scene);
+
+        // The nested composite must have forked: root has only 3 children
+        // (below the threshold), so all chunk tasks are nested-level
+        assertTrue("nested composite must fork its own children",
+                parallelCtx.lastTransformTaskCount >= 2);
+
+        // Identical render queue
+        assertEquals("queued shape count", serialIds.length, parallelIds.length);
+        assertTrue("scene must be big enough to exercise the fork",
+                serialIds.length > 1000);
+        for (int i = 0; i < serialIds.length; i++) {
+            assertEquals("shapeId at position " + i, serialIds[i], parallelIds[i]);
+            assertEquals("Z at position " + i, serialZs[i], parallelZs[i], EPSILON);
+        }
+    }
+
+    @Test
+    public void renderListRebuildDuringInFlightChunkTasksUsesSnapshottedRenderList() throws Exception {
+        System.setProperty("java.awt.headless", "true");
+
+        // One composite with many children: 4 permanent + 4096 in a group
+        // that will be hidden mid-flight to force a render-list rebuild that
+        // REASSIGNS cachedRenderList to a much smaller list.
+        final AbstractCompositeShape composite =
+                new AbstractCompositeShape(new Point3D(0, 0, 400));
+        composite.setRootComposite(true); // skip frustum culling entirely
+        for (int i = 0; i < 4; i++) {
+            composite.addShape(SolidPolygon.triangle(
+                    new Point3D(i * 10, 0, 0), new Point3D(i * 10 + 5, 0, 0),
+                    new Point3D(i * 10, 5, 0), Color.GREEN), "keep");
+        }
+        for (int i = 0; i < 4096; i++) {
+            composite.addShape(SolidPolygon.triangle(
+                    new Point3D(i % 64, i / 64, 0), new Point3D(i % 64 + 1, i / 64, 0),
+                    new Point3D(i % 64, i / 64 + 1, 0), Color.RED), "bulk");
+        }
+
+        executor = Executors.newFixedThreadPool(4);
+
+        // Occupy every pool thread: the chunk tasks submitted below queue
+        // up but cannot start, reproducing the pipeline state where the
+        // NEXT pass's tree walk begins while this pass's chunks are
+        // still pending (the drain runs in the async continuation).
+        final CountDownLatch blockersStarted = new CountDownLatch(4);
+        final CountDownLatch releaseBlockers = new CountDownLatch(1);
+        for (int i = 0; i < 4; i++) {
+            executor.submit(() -> {
+                blockersStarted.countDown();
+                try {
+                    releaseBlockers.await(30, TimeUnit.SECONDS);
+                } catch (final InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+            });
+        }
+        assertTrue("pool blockers must be running",
+                blockersStarted.await(30, TimeUnit.SECONDS));
+
+        // Pass P: forks chunk tasks against the 4100-entry render list.
+        final ParallelTransformCoordinator coordinator =
+                new ParallelTransformCoordinator(executor);
+        final RenderingContext passContext = new RenderingContext(W, H, 1);
+        passContext.prepareForNewFrameRendering();
+        passContext.transformCoordinator = coordinator;
+        final RenderAggregator aggregator = new RenderAggregator();
+        composite.transform(new TransformStack(), aggregator, passContext);
+        assertTrue("composite must have forked chunk tasks",
+                coordinator.getSubmittedTaskCount() >= 2);
+
+        // Pass P+1's tree walk arrives while P's chunk tasks are still
+        // queued: hiding "bulk" forces a render-list rebuild that reassigns
+        // cachedRenderList to a 4-entry list. (No coordinator on this
+        // context -> serial path, itself immune to the race.)
+        composite.hideGroup("bulk");
+        final RenderingContext nextContext = new RenderingContext(W, H, 1);
+        nextContext.prepareForNewFrameRendering();
+        composite.transform(new TransformStack(), new RenderAggregator(), nextContext);
+
+        // Now P's chunk tasks run. They must iterate the SAME list
+        // instance their chunk ranges were computed against — re-reading
+        // the reassigned field threw IndexOutOfBoundsException (observed
+        // in production: "Index 258 out of bounds for length 24").
+        releaseBlockers.countDown();
+        coordinator.drainAndMergeInto(aggregator);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/RadixLongSortTest.java
new file mode 100644 (file)
index 0000000..56180c0
--- /dev/null
@@ -0,0 +1,95 @@
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import org.junit.Test;
+
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Verifies the radix sort core: key mapping reproduces the Z-comparator's
+ * ordering semantics, and the pair sort is sorted and stable.
+ */
+public class RadixLongSortTest {
+
+    /** The comparator's Z comparison: -1/0/+1 with NaN treated as "equal". */
+    private static int compareZ(final double z1, final double z2) {
+        if (z1 < z2)
+            return 1;  // painter order: larger z first
+        if (z1 > z2)
+            return -1;
+        return 0;
+    }
+
+    @Test
+    public void keyOrderMatchesComparator() {
+        final double[] specials = {
+                0.0d, -0.0d, 1.0d, -1.0d, 5e-3d, 96.0d, 1e6d,
+                Double.MIN_VALUE, -Double.MIN_VALUE,
+                Double.MAX_VALUE, -Double.MAX_VALUE,
+                Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY,
+                123456.789d, -123456.789d
+        };
+        final Random random = new Random(42);
+        final double[] values = new double[specials.length + 500];
+        System.arraycopy(specials, 0, values, 0, specials.length);
+        for (int i = specials.length; i < values.length; i++)
+            values[i] = (random.nextDouble() - 0.5) * 2e6;
+        for (final double z1 : values)
+            for (final double z2 : values) {
+                final int expected = compareZ(z1, z2);
+                final int actual = Long.compareUnsigned(
+                        RadixLongSort.zSortKey(z1), RadixLongSort.zSortKey(z2));
+                assertTrue("z1=" + z1 + " z2=" + z2 + " expected sign "
+                                + expected + " got " + actual,
+                        Integer.signum(expected) == Integer.signum(actual));
+            }
+    }
+
+    @Test
+    public void minusZeroAndPlusZeroShareOneKey() {
+        assertEquals(RadixLongSort.zSortKey(0.0d),
+                RadixLongSort.zSortKey(-0.0d));
+    }
+
+    @Test
+    public void sortPairsIsSortedAndStable() {
+        final Random random = new Random(7);
+        for (final int n : new int[]{0, 1, 2, 100, 10000}) {
+            final long[] keys = new long[Math.max(n, 1)];
+            final long[] keysTmp = new long[Math.max(n, 1)];
+            final int[] idx = new int[Math.max(n, 1)];
+            final int[] idxTmp = new int[Math.max(n, 1)];
+            // Duplicate-heavy keys exercise stability
+            for (int i = 0; i < n; i++) {
+                keys[i] = random.nextInt(50);
+                idx[i] = i;
+            }
+            RadixLongSort.sortPairs(keys, idx, n, keysTmp, idxTmp);
+            for (int i = 1; i < n; i++) {
+                assertTrue("sorted at " + i,
+                        Long.compareUnsigned(keys[i - 1], keys[i]) <= 0);
+                if (keys[i - 1] == keys[i])
+                    assertTrue("stable at " + i, idx[i - 1] < idx[i]);
+            }
+        }
+    }
+
+    @Test
+    public void sortPairsHandlesFullUnsignedRange() {
+        final Random random = new Random(99);
+        final int n = 5000;
+        final long[] keys = new long[n];
+        final long[] keysTmp = new long[n];
+        final int[] idx = new int[n];
+        final int[] idxTmp = new int[n];
+        for (int i = 0; i < n; i++) {
+            keys[i] = random.nextLong(); // full range incl. "negative"
+            idx[i] = i;
+        }
+        RadixLongSort.sortPairs(keys, idx, n, keysTmp, idxTmp);
+        for (int i = 1; i < n; i++)
+            assertTrue(Long.compareUnsigned(keys[i - 1], keys[i]) <= 0);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/SegmentBinningTest.java
new file mode 100644 (file)
index 0000000..ef7c9af
--- /dev/null
@@ -0,0 +1,286 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.SegmentRenderingContext;
+import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
+import eu.svjatoslav.aukio.e3d.math.Transform;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.GlowingPoint;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureGenerator;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Verifies that paint tile binning produces pixel-identical output to
+ * painting the full sorted queue in every tile: binning must only skip
+ * shapes that cannot touch a tile's rectangle, never shapes that can.
+ *
+ * <p>The scene deliberately includes shapes whose paint output extends
+ * past their vertex bounds on both axes (thick lines, glowing point
+ * billboards, text glyphs) to exercise the per-shape X/Y margins.</p>
+ */
+public class SegmentBinningTest {
+
+    private static final int W = 1280, H = 720;
+    private static final int TILES_X = 4, TILES_Y = 3;
+    private static final int TILE_COUNT = TILES_X * TILES_Y;
+
+    private ExecutorService executor;
+
+    @After
+    public void tearDown() {
+        if (executor != null) {
+            executor.shutdownNow();
+        }
+    }
+
+    private ShapeCollection buildScene(final ViewPanel panel, final int sphereCount) {
+        final ShapeCollection scene = panel.getRootShapeCollection();
+        panel.getCamera().getTransform().setTranslation(new Point3D(0, 0, -600));
+
+        // Solid spheres spread across the whole viewport
+        for (int i = 0; i < sphereCount; i++) {
+            scene.addShape(new SolidPolygonSphere(
+                    new Point3D((i % 6 - 3) * 90, (i / 6 - 2) * 80, 400),
+                    30, 10, Color.GREEN));
+        }
+
+        // Wireframe cubes (thin lines)
+        final LineAppearance appearance = new LineAppearance(2.0, Color.CYAN);
+        for (int i = 0; i < 30; i++) {
+            scene.addShape(new WireframeCube(
+                    new Point3D((i % 10 - 5) * 70, (i / 10 - 1) * 90, 700),
+                    20, appearance));
+        }
+
+        // Thick lines crossing tile boundaries on both axes
+        scene.addShape(new Line(
+                new Point3D(-300, -200, 300), new Point3D(300, 250, 300),
+                Color.YELLOW, 8.0));
+        scene.addShape(new Line(
+                new Point3D(-250, 250, 350), new Point3D(250, -250, 350),
+                Color.RED, 6.0));
+        // Vertical thick line: X margin matters, Y range covers all rows
+        scene.addShape(new Line(
+                new Point3D(0, -300, 300), new Point3D(0, 300, 300),
+                Color.GREEN, 8.0));
+
+        // Glowing points (billboards): quad extends far beyond the vertex
+        for (int i = 0; i < 8; i++) {
+            scene.addShape(new GlowingPoint(
+                    new Point3D((i % 4 - 2) * 120, (i / 4 - 0.5) * 150, 250),
+                    40, Color.WHITE));
+        }
+
+        // Text canvas: glyph extends beyond the character cell vertices
+        final TextCanvas text = new TextCanvas(
+                new Transform(new Point3D(-100, -50, 500)),
+                "Binning!", Color.WHITE, Color.BLUE);
+        scene.addShape(text);
+
+        // A quad and a cube for variety
+        scene.addShape(SolidPolygon.quad(
+                new Point3D(-120, -120, 300), new Point3D(120, -120, 300),
+                new Point3D(120, 120, 300), new Point3D(-120, 120, 300),
+                Color.WHITE));
+        scene.addShape(new SolidPolygonCube(new Point3D(200, 100, 350), 40, Color.RED));
+
+        // Large textured triangles spanning multiple tile rows. Big
+        // on screen — exercises textured-triangle binning, whose bounds
+        // formerly stayed (0,0) and binned it into the topmost segment only.
+        final Texture texture = TextureGenerator.solidWithBorder(
+                32, Color.YELLOW, Color.WHITE, 2, 1);
+        scene.addShape(new TexturedTriangle(
+                new Vertex(new Point3D(-400, -300, 250), new Point2D(0, 0)),
+                new Vertex(new Point3D(400, -100, 250), new Point2D(1, 0)),
+                new Vertex(new Point3D(0, 400, 250), new Point2D(0.5, 1)),
+                texture));
+        scene.addShape(new TexturedTriangle(
+                new Vertex(new Point3D(-500, 100, 300), new Point2D(0, 0)),
+                new Vertex(new Point3D(-100, -50, 300), new Point2D(1, 0)),
+                new Vertex(new Point3D(-300, 500, 300), new Point2D(0.5, 1)),
+                texture));
+
+        return scene;
+    }
+
+    /**
+     * Paints the current frame's sorted queue into a fresh context using
+     * one full-range pass (no binning possible).
+     */
+    private RenderingContext paintReference(final ShapeCollection scene) {
+        final RenderingContext reference = new RenderingContext(W, H, TILES_X, TILES_Y, 1);
+        scene.paintShapes(reference);
+        return reference;
+    }
+
+    /**
+     * Paints the current frame's sorted queue tile-by-tile into a fresh
+     * context, using whatever bins are currently active.
+     */
+    private RenderingContext paintTiled(final ShapeCollection scene) {
+        final RenderingContext tiled = new RenderingContext(W, H, TILES_X, TILES_Y, 1);
+        final int tileW = W / TILES_X;
+        final int tileH = H / TILES_Y;
+        for (int ty = 0; ty < TILES_Y; ty++) {
+            final int minY = ty * tileH;
+            final int maxY = (ty == TILES_Y - 1) ? H : (ty + 1) * tileH;
+            for (int tx = 0; tx < TILES_X; tx++) {
+                final int minX = tx * tileW;
+                final int maxX = (tx == TILES_X - 1) ? W : (tx + 1) * tileW;
+                final SegmentRenderingContext tileContext = new SegmentRenderingContext(
+                        tiled, minY, maxY, ty * TILES_X + tx);
+                tileContext.renderMinX = minX;
+                tileContext.renderMaxX = maxX;
+                scene.paintShapes(tileContext);
+            }
+        }
+        return tiled;
+    }
+
+    private void assertPixelsEqual(final RenderingContext expected,
+                                   final RenderingContext actual) {
+        final int tileW = W / TILES_X;
+        final int tileH = H / TILES_Y;
+        for (int i = 0; i < expected.pixels.length; i++) {
+            if (expected.pixels[i] != actual.pixels[i]) {
+                final int x = i % W;
+                final int y = i / W;
+                fail("pixel mismatch at (" + x + "," + y + ") tile ("
+                        + Math.min(x / tileW, TILES_X - 1) + ","
+                        + Math.min(y / tileH, TILES_Y - 1) + ")"
+                        + ": expected=" + Integer.toHexString(expected.pixels[i])
+                        + " actual=" + Integer.toHexString(actual.pixels[i]));
+            }
+        }
+    }
+
+    private void transformAndSort(final ViewPanel panel, final ShapeCollection scene,
+                                  final RenderingContext context) {
+        context.prepareForNewFrameRendering();
+        scene.transformShapes(panel, context);
+        scene.sortShapes();
+    }
+
+    @Test
+    public void serialBinningMatchesFullQueuePaint() {
+        System.setProperty("java.awt.headless", "true");
+        final ViewPanel panel = new ViewPanel();
+        final ShapeCollection scene = buildScene(panel, 24);
+
+        // Transform and sort once; paint is read-only on shape state,
+        // so the same frame can be painted into multiple buffers.
+        transformAndSort(panel, scene, new RenderingContext(W, H, TILES_X, TILES_Y, 1));
+
+        // The scene must contain textured triangles spanning multiple
+        // tiles, otherwise the binning-bounds regression (bounds left at
+        // 0,0 -> binned into the topmost segment only) is not covered
+        boolean anyTextured = false;
+        for (final AbstractCoordinateShape shape : scene.getQueuedShapes()) {
+            if (shape instanceof TexturedTriangle) {
+                anyTextured = true;
+                break;
+            }
+        }
+        assertTrue("scene must contain textured triangles",
+                anyTextured);
+
+        final RenderingContext reference = paintReference(scene);
+
+        // Serial binning (null executor)
+        scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null);
+        final int[] binSizes = scene.getBinSizes();
+        assertNotNull("bins must be built", binSizes);
+        assertEquals(TILE_COUNT, binSizes.length);
+
+        // Binning must reduce total per-tile iterations versus every
+        // tile iterating the whole queue
+        final int queueSize = scene.getQueuedShapeCount();
+        int totalBinEntries = 0;
+        for (final int size : binSizes) {
+            totalBinEntries += size;
+        }
+        assertTrue("binning must reduce total paint iterations ("
+                        + totalBinEntries + " >= " + (TILE_COUNT * queueSize) + ")",
+                totalBinEntries < TILE_COUNT * queueSize);
+
+        assertPixelsEqual(reference, paintTiled(scene));
+    }
+
+    @Test
+    public void parallelBinningMatchesSerialAndFullQueuePaint() {
+        System.setProperty("java.awt.headless", "true");
+        final ViewPanel panel = new ViewPanel();
+        // Enough shapes to exceed the parallel binning threshold (8192)
+        final ShapeCollection scene = buildScene(panel, 80);
+
+        transformAndSort(panel, scene, new RenderingContext(W, H, TILES_X, TILES_Y, 1));
+        final int queueSize = scene.getQueuedShapeCount();
+        assertTrue("scene must exceed the parallel binning threshold",
+                queueSize > 8192);
+        final RenderingContext reference = paintReference(scene);
+
+        // Serial bins -> tiled paint A
+        scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null);
+        final int[] serialBinSizes = scene.getBinSizes();
+        final RenderingContext serialTiled = paintTiled(scene);
+
+        // Parallel bins -> tiled paint B
+        executor = Executors.newFixedThreadPool(8);
+        scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, executor);
+        final int[] parallelBinSizes = scene.getBinSizes();
+        final RenderingContext parallelTiled = paintTiled(scene);
+
+        // Same bin layout, same pixels
+        org.junit.Assert.assertArrayEquals(serialBinSizes, parallelBinSizes);
+        assertPixelsEqual(reference, serialTiled);
+        assertPixelsEqual(reference, parallelTiled);
+    }
+
+    @Test
+    public void fullRangeContextFallsBackToFullQueue() {
+        System.setProperty("java.awt.headless", "true");
+        final ViewPanel panel = new ViewPanel();
+        final ShapeCollection scene = buildScene(panel, 24);
+
+        final RenderingContext context = new RenderingContext(W, H, TILES_X, TILES_Y, 1);
+        transformAndSort(panel, scene, context);
+        scene.binShapesForTiles(TILES_X, TILES_Y, 0, W, H, null);
+
+        // A full-range context matches no single tile: must paint the full
+        // queue (verified by it producing non-background pixels at all)
+        scene.paintShapes(context);
+        boolean anyPainted = false;
+        for (final int pixel : context.pixels) {
+            if (pixel != 0) {
+                anyPainted = true;
+                break;
+            }
+        }
+        assertTrue("full-range paint must render shapes", anyPainted);
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangleBlendTest.java
new file mode 100644 (file)
index 0000000..881e716
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.util.Random;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Pixel-exactness proof for the optimized textured scanline renderer:
+ * the one-multiply alpha blend and the clamp-free fast path must produce
+ * output identical to the legacy implementation, bit for bit.
+ */
+public class TexturedTriangleBlendTest {
+
+    /** Legacy two-multiply blend, the original semantics. */
+    private static int legacyBlendChannel(final int src, final int dest, final int alpha) {
+        return ((dest * (255 - alpha)) + (src * alpha)) >> 8;
+    }
+
+    /** Optimized one-multiply blend, must equal legacy for every input. */
+    private static int fastBlendChannel(final int src, final int dest, final int alpha) {
+        return dest + ((alpha * (src - dest) - dest) >> 8);
+    }
+
+    @Test
+    public void oneMultiplyBlendMatchesLegacyBlend() {
+        final int[] channelValues = {0, 1, 2, 63, 127, 128, 200, 254, 255};
+        for (int alpha = 0; alpha <= 255; alpha++) {
+            for (final int src : channelValues) {
+                for (final int dest : channelValues) {
+                    assertEquals("src=" + src + " dest=" + dest + " alpha=" + alpha,
+                            legacyBlendChannel(src, dest, alpha),
+                            fastBlendChannel(src, dest, alpha));
+                }
+            }
+        }
+        final Random random = new Random(42);
+        for (int i = 0; i < 1_000_000; i++) {
+            final int src = random.nextInt(256);
+            final int dest = random.nextInt(256);
+            final int alpha = random.nextInt(256);
+            assertEquals("src=" + src + " dest=" + dest + " alpha=" + alpha,
+                    legacyBlendChannel(src, dest, alpha),
+                    fastBlendChannel(src, dest, alpha));
+        }
+    }
+
+    /**
+     * Legacy scanline implementation (pre-optimization), used as the
+     * oracle: the optimized drawHorizontalLineZ must match it exactly.
+     */
+    private static void legacyDrawHorizontalLine(
+            final PolygonBorderInterpolator line1, final PolygonBorderInterpolator line2,
+            final int y, final int[] renderBufferPixels, final int width,
+            final int renderMinX, final int renderMaxX,
+            final TextureBitmap textureBitmap) {
+        line1.setCurrentY(y);
+        line2.setCurrentY(y);
+
+        int x1 = line1.getX();
+        int x2 = line2.getX();
+
+        final double tx2, ty2;
+        final double tx1, ty1;
+
+        if (x1 <= x2) {
+            tx1 = line1.getTX() * textureBitmap.multiplicationFactor;
+            ty1 = line1.getTY() * textureBitmap.multiplicationFactor;
+            tx2 = line2.getTX() * textureBitmap.multiplicationFactor;
+            ty2 = line2.getTY() * textureBitmap.multiplicationFactor;
+        } else {
+            final int tmp = x1;
+            x1 = x2;
+            x2 = tmp;
+            tx1 = line2.getTX() * textureBitmap.multiplicationFactor;
+            ty1 = line2.getTY() * textureBitmap.multiplicationFactor;
+            tx2 = line1.getTX() * textureBitmap.multiplicationFactor;
+            ty2 = line1.getTY() * textureBitmap.multiplicationFactor;
+        }
+
+        final double realWidth = x2 - x1;
+        final double realX1 = x1;
+
+        if (x1 < renderMinX)
+            x1 = renderMinX;
+        if (x2 >= renderMaxX)
+            x2 = renderMaxX;
+
+        int renderBufferOffset = (y * width) + x1;
+
+        final double twidth = tx2 - tx1;
+        final double theight = ty2 - ty1;
+
+        final double txStep = twidth / realWidth;
+        final double tyStep = theight / realWidth;
+
+        double tx = tx1 + txStep * (x1 - realX1);
+        double ty = ty1 + tyStep * (x1 - realX1);
+
+        final int[] texPixels = textureBitmap.pixels;
+        final int texW = textureBitmap.width;
+        final int texH = textureBitmap.height;
+        final int texWMinus1 = texW - 1;
+        final int texHMinus1 = texH - 1;
+
+        for (int x = x1; x < x2; x++) {
+            int itx = (int) tx;
+            int ity = (int) ty;
+
+            if (itx < 0) itx = 0;
+            else if (itx > texWMinus1) itx = texWMinus1;
+
+            if (ity < 0) ity = 0;
+            else if (ity > texHMinus1) ity = texHMinus1;
+
+            final int srcPixel = texPixels[ity * texW + itx];
+            final int srcAlpha = (srcPixel >> 24) & 0xff;
+
+            if (srcAlpha != 0) {
+                if (srcAlpha == 255) {
+                    renderBufferPixels[renderBufferOffset] = srcPixel;
+                } else {
+                    final int destPixel = renderBufferPixels[renderBufferOffset];
+                    final int destR = (destPixel >> 16) & 0xff;
+                    final int destG = (destPixel >> 8) & 0xff;
+                    final int destB = destPixel & 0xff;
+
+                    final int r = legacyBlendChannel((srcPixel >> 16) & 0xff, destR, srcAlpha);
+                    final int g = legacyBlendChannel((srcPixel >> 8) & 0xff, destG, srcAlpha);
+                    final int b = legacyBlendChannel(srcPixel & 0xff, destB, srcAlpha);
+
+                    renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+                }
+            }
+
+            tx += txStep;
+            ty += tyStep;
+            renderBufferOffset++;
+        }
+    }
+
+    @Test
+    public void scanlineMatchesLegacyImplementation() throws Exception {
+        final int width = 96;
+        final int height = 8;
+        final Random random = new Random(1337);
+
+        // Texture with a mix of transparent, semi-transparent and opaque pixels
+        final int texW = 16, texH = 16;
+        final int[] texPixels = new int[texW * texH];
+        for (int i = 0; i < texPixels.length; i++) {
+            final int alpha;
+            switch (random.nextInt(4)) {
+                case 0: alpha = 0; break;
+                case 1: alpha = 255; break;
+                default: alpha = 1 + random.nextInt(254);
+            }
+            texPixels[i] = (alpha << 24) | (random.nextInt(256) << 16)
+                    | (random.nextInt(256) << 8) | random.nextInt(256);
+        }
+        final TextureBitmap textureBitmap = new TextureBitmap(texW, texH, texPixels, 1.0);
+
+        final TexturedTriangle triangle = new TexturedTriangle(
+                new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)),
+                new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)),
+                new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null);
+
+        final Method draw = TexturedTriangle.class.getDeclaredMethod("drawHorizontalLineZ",
+                PolygonBorderInterpolator.class, PolygonBorderInterpolator.class,
+                int.class, RenderingContext.class, TextureBitmap.class);
+        draw.setAccessible(true);
+
+        for (int iteration = 0; iteration < 5000; iteration++) {
+            // Random span endpoints, including out-of-texture and
+            // out-of-render-bounds cases, and reversed X order
+            final double sx1 = random.nextDouble() * width * 1.5 - width * 0.25;
+            final double sx2 = random.nextDouble() * width * 1.5 - width * 0.25;
+            final double u1 = random.nextDouble() * 2.0 - 0.5;
+            final double v1 = random.nextDouble() * 2.0 - 0.5;
+            final double u2 = random.nextDouble() * 2.0 - 0.5;
+            final double v2 = random.nextDouble() * 2.0 - 0.5;
+            final int y = 1 + random.nextInt(height - 2);
+
+            final PolygonBorderInterpolator line1 = new PolygonBorderInterpolator();
+            final PolygonBorderInterpolator line2 = new PolygonBorderInterpolator();
+            line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1),
+                    new Point2D(u1, v1), new Point2D(u1, v1));
+            line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1),
+                    new Point2D(u2, v2), new Point2D(u2, v2));
+
+            final int[] actual = new int[width * height];
+            final int[] expected = new int[width * height];
+            for (int i = 0; i < actual.length; i++) {
+                actual[i] = expected[i] = 0xFF000000 | random.nextInt(0xFFFFFF);
+            }
+
+            final RenderingContext context = new RenderingContext(width, height, 1);
+            System.arraycopy(actual, 0, context.pixels, 0, actual.length);
+            context.renderMinX = 0;
+            context.renderMaxX = width;
+
+            // Fresh interpolators for the oracle (setCurrentY mutates them)
+            final PolygonBorderInterpolator oLine1 = new PolygonBorderInterpolator();
+            final PolygonBorderInterpolator oLine2 = new PolygonBorderInterpolator();
+            oLine1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1),
+                    new Point2D(u1, v1), new Point2D(u1, v1));
+            oLine2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1),
+                    new Point2D(u2, v2), new Point2D(u2, v2));
+
+            java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY);
+            draw.invoke(triangle, line1, line2, y, context, textureBitmap);
+            legacyDrawHorizontalLine(oLine1, oLine2, y, expected, width,
+                    0, width, textureBitmap);
+
+            for (int i = 0; i < expected.length; i++) {
+                if (expected[i] != context.pixels[i]) {
+                    final int px = i % width, py = i / width;
+                    throw new AssertionError("iteration " + iteration
+                            + " pixel(" + px + "," + py + "): expected "
+                            + Integer.toHexString(expected[i]) + " but got "
+                            + Integer.toHexString(context.pixels[i])
+                            + " [span " + sx1 + ".." + sx2 + " uv ("
+                            + u1 + "," + v1 + ")->(" + u2 + "," + v2 + ")]");
+                }
+            }
+        }
+    }
+}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTrianglePerspectiveTest.java
new file mode 100644 (file)
index 0000000..0c04e7f
--- /dev/null
@@ -0,0 +1,305 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point2D;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.gui.RenderingContext;
+import eu.svjatoslav.aukio.e3d.math.Vertex;
+import eu.svjatoslav.aukio.e3d.renderer.raster.texture.TextureBitmap;
+import org.junit.Test;
+
+import java.lang.reflect.Method;
+import java.util.Random;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Correctness proof for the Quake-style subdivided perspective scanline
+ * renderer: compared against an exact per-pixel divide oracle that mirrors
+ * the production span walk, texel selection must never deviate by more
+ * than one texel per axis (the ulp-boundary artifact inherent to
+ * truncation, present in the affine path as well).
+ */
+public class TexturedTrianglePerspectiveTest {
+
+    private static final int TEX_W = 64;
+    private static final int TEX_H = 64;
+
+    private static TextureBitmap numberedTexture() {
+        final int[] texPixels = new int[TEX_W * TEX_H];
+        for (int i = 0; i < texPixels.length; i++)
+            texPixels[i] = 0xFF000000 | i;
+        return new TextureBitmap(TEX_W, TEX_H, texPixels, 1.0);
+    }
+
+    /**
+     * Exact oracle: replicates the production span setup (rounded
+     * endpoints, gradients over the unclipped width, clip compensation),
+     * but recovers u/v with a division at EVERY pixel.
+     */
+    private static void exactDrawHorizontalLine(
+            final double su1, final double sv1, final double sw1,
+            final double su2, final double sv2, final double sw2,
+            final int rx1, final int rx2,
+            final int clipMinX, final int clipMaxX,
+            final int y, final int[] renderBufferPixels, final int width,
+            final TextureBitmap textureBitmap) {
+
+        final double realWidth = rx2 - rx1;
+        int x1 = Math.max(rx1, clipMinX);
+        int x2 = Math.min(rx2, clipMaxX);
+        if (x2 - x1 <= 0)
+            return;
+
+        final double dsu = (su2 - su1) / realWidth;
+        final double dsv = (sv2 - sv1) / realWidth;
+        final double dsw = (sw2 - sw1) / realWidth;
+
+        double su = su1 + dsu * (x1 - rx1);
+        double sv = sv1 + dsv * (x1 - rx1);
+        double sw = sw1 + dsw * (x1 - rx1);
+
+        int renderBufferOffset = (y * width) + x1;
+
+        final int[] texPixels = textureBitmap.pixels;
+
+        for (int x = x1; x < x2; x++) {
+            final double invW = 1d / sw;
+            int itx = (int) (su * invW);
+            int ity = (int) (sv * invW);
+
+            if (itx < 0) itx = 0;
+            else if (itx > TEX_W - 1) itx = TEX_W - 1;
+            if (ity < 0) ity = 0;
+            else if (ity > TEX_H - 1) ity = TEX_H - 1;
+
+            renderBufferPixels[renderBufferOffset] = texPixels[ity * TEX_W + itx];
+
+            su += dsu;
+            sv += dsv;
+            sw += dsw;
+            renderBufferOffset++;
+        }
+    }
+
+    @Test
+    public void subdividedPerspectiveStaysWithinOneTexelOfExact() throws Exception {
+        final int width = 256;
+        final int height = 8;
+        final Random random = new Random(2026);
+        final TextureBitmap textureBitmap = numberedTexture();
+
+        final TexturedTriangle triangle = new TexturedTriangle(
+                new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)),
+                new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)),
+                new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null);
+
+        final Method draw = TexturedTriangle.class.getDeclaredMethod(
+                "drawHorizontalLinePerspectiveZ",
+                PerspectiveBorderInterpolator.class, PerspectiveBorderInterpolator.class,
+                int.class, RenderingContext.class, TextureBitmap.class);
+        draw.setAccessible(true);
+
+        long totalPixels = 0;
+        long identicalPixels = 0;
+        int maxDeviation = 0;
+
+        for (int iteration = 0; iteration < 3000; iteration++) {
+            // Random steep-perspective span: z varies up to 60x across the
+            // span, texture coords may overshoot the texture (clamp path),
+            // and the span may extend past the render bounds (clip path)
+            final double sx1 = random.nextDouble() * width * 0.5;
+            final double sx2 = sx1 + 4 + random.nextDouble() * (width - 8);
+            final double z1 = 0.5 + random.nextDouble() * 31.5;
+            final double z2 = 0.5 + random.nextDouble() * 31.5;
+            final double u1 = random.nextDouble() * 144 - 16;
+            final double v1 = random.nextDouble() * 144 - 16;
+            final double u2 = random.nextDouble() * 144 - 16;
+            final double v2 = random.nextDouble() * 144 - 16;
+            final int y = 1 + random.nextInt(height - 2);
+
+            final double sw1 = 1d / z1, sw2 = 1d / z2;
+            final double su1 = u1 * sw1, sv1 = v1 * sw1;
+            final double su2 = u2 * sw2, sv2 = v2 * sw2;
+
+            final PerspectiveBorderInterpolator line1 = new PerspectiveBorderInterpolator();
+            final PerspectiveBorderInterpolator line2 = new PerspectiveBorderInterpolator();
+            line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1), su1, sv1, sw1, su1, sv1, sw1);
+            line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1), su2, sv2, sw2, su2, sv2, sw2);
+
+            final RenderingContext context = new RenderingContext(width, height, 1);
+            context.renderMinX = 0;
+            context.renderMaxX = width;
+
+            java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY);
+            draw.invoke(triangle, line1, line2, y, context, textureBitmap);
+
+            final int rx1 = (int) Math.round(sx1);
+            final int rx2 = (int) Math.round(sx2);
+            final int[] expected = new int[width * height];
+            exactDrawHorizontalLine(su1, sv1, sw1, su2, sv2, sw2,
+                    rx1, rx2, 0, width, y, expected, width, textureBitmap);
+
+            final int cx1 = Math.max(rx1, 0);
+            final int cx2 = Math.min(rx2, width);
+            for (int x = cx1; x < cx2; x++) {
+                final int a = context.pixels[y * width + x] & 0xFFFFFF;
+                final int e = expected[y * width + x] & 0xFFFFFF;
+                totalPixels++;
+                if (a == e) {
+                    identicalPixels++;
+                } else {
+                    final int deviation = Math.max(
+                            Math.abs((a % TEX_W) - (e % TEX_W)),
+                            Math.abs((a / TEX_W) - (e / TEX_W)));
+                    maxDeviation = Math.max(maxDeviation, deviation);
+                }
+            }
+        }
+
+        final double identicalRatio = (double) identicalPixels / totalPixels;
+        if (maxDeviation > 1) {
+            fail("texel deviation " + maxDeviation + " exceeds 1 (identical="
+                    + (identicalRatio * 100) + "% over " + totalPixels + " pixels)");
+        }
+        assertTrue("suspiciously few pixels tested: " + totalPixels, totalPixels > 100000);
+        System.out.println("perspective-16 vs exact: identical=" + (identicalRatio * 100)
+                + "% maxTexelDeviation=" + maxDeviation
+                + " over " + totalPixels + " pixels");
+    }
+
+    @Test
+    public void affineWithinHalfTexelBound() throws Exception {
+        // The paint() shortcut uses affine mapping when
+        // texelSpan * (zRatio-1) < 2. Verify: for random spans satisfying
+        // that bound, the affine renderer stays within one texel of the
+        // exact per-pixel divide oracle.
+        final int width = 320;
+        final int height = 8;
+        final Random random = new Random(77);
+        final TextureBitmap textureBitmap = numberedTexture();
+
+        final TexturedTriangle triangle = new TexturedTriangle(
+                new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)),
+                new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)),
+                new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null);
+
+        final Method drawAffine = TexturedTriangle.class.getDeclaredMethod(
+                "drawHorizontalLineZ",
+                PolygonBorderInterpolator.class, PolygonBorderInterpolator.class,
+                int.class, RenderingContext.class, TextureBitmap.class);
+        drawAffine.setAccessible(true);
+
+        long totalPixels = 0;
+        int maxDeviation = 0;
+
+        for (int iteration = 0; iteration < 3000; iteration++) {
+            final double sx1 = random.nextDouble() * width * 0.5;
+            final double spanD = 4 + random.nextDouble() * 296;
+            final double sx2 = Math.min(sx1 + spanD, width * 1.2);
+            final double u1 = random.nextDouble() * 56;
+            final double v1 = random.nextDouble() * 56;
+            final double u2 = random.nextDouble() * 56;
+            final double v2 = random.nextDouble() * 56;
+            // z ratio strictly inside the bound, driven by the TEXEL span
+            final double texelSpan = Math.max(Math.abs(u2 - u1), Math.abs(v2 - v1));
+            final double z1 = 1 + random.nextDouble() * 30;
+            final double r = 1 + random.nextDouble() * (1.9 / Math.max(texelSpan, 0.5));
+            final double z2 = z1 * r;
+            final int y = 1 + random.nextInt(height - 2);
+
+            final PolygonBorderInterpolator line1 = new PolygonBorderInterpolator();
+            final PolygonBorderInterpolator line2 = new PolygonBorderInterpolator();
+            line1.setPoints(new Point2D(sx1, y), new Point2D(sx1, y + 1),
+                    new Point2D(u1, v1), new Point2D(u1, v1));
+            line2.setPoints(new Point2D(sx2, y), new Point2D(sx2, y + 1),
+                    new Point2D(u2, v2), new Point2D(u2, v2));
+
+            final RenderingContext context = new RenderingContext(width, height, 1);
+            context.renderMinX = 0;
+            context.renderMaxX = width;
+
+            java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY);
+            drawAffine.invoke(triangle, line1, line2, y, context, textureBitmap);
+
+            // Exact oracle over the same span (gradients from z1/z2)
+            final double sw1 = 1d / z1, sw2 = 1d / z2;
+            final int rx1 = (int) Math.round(sx1);
+            final int rx2 = (int) Math.round(sx2);
+            final int[] expected = new int[width * height];
+            exactDrawHorizontalLine(u1 * sw1, v1 * sw1, sw1, u2 * sw2, v2 * sw2, sw2,
+                    rx1, rx2, 0, width, y, expected, width, textureBitmap);
+
+            final int cx1 = Math.max(rx1, 0);
+            final int cx2 = Math.min(rx2, width);
+            for (int x = cx1; x < cx2; x++) {
+                final int a = context.pixels[y * width + x] & 0xFFFFFF;
+                final int e = expected[y * width + x] & 0xFFFFFF;
+                totalPixels++;
+                final int deviation = Math.max(
+                        Math.abs((a % TEX_W) - (e % TEX_W)),
+                        Math.abs((a / TEX_W) - (e / TEX_W)));
+                maxDeviation = Math.max(maxDeviation, deviation);
+            }
+        }
+
+        if (maxDeviation > 1) {
+            fail("affine deviation " + maxDeviation + " exceeds 1 texel within the bound"
+                    + " over " + totalPixels + " pixels");
+        }
+        assertTrue("suspiciously few pixels tested: " + totalPixels, totalPixels > 100000);
+        System.out.println("affine within bound: maxTexelDeviation=" + maxDeviation
+                + " over " + totalPixels + " pixels");
+    }
+
+    @Test
+    public void faceOnSpanMatchesAffineWithinOneTexel() throws Exception {
+        // Constant z across the span: perspective correction must reduce
+        // to the affine mapping (u linear in x), modulo the ulp-boundary
+        // truncation artifact.
+        final int width = 200;
+        final int height = 4;
+        final TextureBitmap textureBitmap = numberedTexture();
+
+        final TexturedTriangle triangle = new TexturedTriangle(
+                new Vertex(new Point3D(0, 0, 0), new Point2D(0, 0)),
+                new Vertex(new Point3D(1, 0, 0), new Point2D(1, 0)),
+                new Vertex(new Point3D(0, 1, 0), new Point2D(0, 1)), null);
+
+        final Method draw = TexturedTriangle.class.getDeclaredMethod(
+                "drawHorizontalLinePerspectiveZ",
+                PerspectiveBorderInterpolator.class, PerspectiveBorderInterpolator.class,
+                int.class, RenderingContext.class, TextureBitmap.class);
+        draw.setAccessible(true);
+
+        final double z = 5.0;
+        final double sw = 1d / z;
+        final int y = 1;
+        final double u1 = 2.0, v1 = 3.0, u2 = 30.0, v2 = 10.0;
+
+        final PerspectiveBorderInterpolator line1 = new PerspectiveBorderInterpolator();
+        final PerspectiveBorderInterpolator line2 = new PerspectiveBorderInterpolator();
+        line1.setPoints(new Point2D(10, y), new Point2D(10, y + 1), u1 * sw, v1 * sw, sw, u1 * sw, v1 * sw, sw);
+        line2.setPoints(new Point2D(190, y), new Point2D(190, y + 1), u2 * sw, v2 * sw, sw, u2 * sw, v2 * sw, sw);
+
+        final RenderingContext context = new RenderingContext(width, height, 1);
+        context.renderMinX = 0;
+        context.renderMaxX = width;
+        java.util.Arrays.fill(context.depth, Float.NEGATIVE_INFINITY);
+        draw.invoke(triangle, line1, line2, y, context, textureBitmap);
+
+        for (int x = 10; x < 190; x++) {
+            final double exactU = u1 + (u2 - u1) * (x - 10) / 180.0;
+            final double exactV = v1 + (v2 - v1) * (x - 10) / 180.0;
+            final int actualTexel = context.pixels[y * width + x] & 0xFFFFFF;
+            final int du = Math.abs((actualTexel % TEX_W) - ((int) exactU));
+            final int dv = Math.abs((actualTexel / TEX_W) - ((int) exactV));
+            assertTrue("pixel " + x + ": texel deviation u=" + du + " v=" + dv,
+                    du <= 1 && dv <= 1);
+        }
+    }
+}