--- /dev/null
+/.idea/
+/target/
+/.classpath
+/.project
+/.settings/
+/doc/graphs/
+/doc/apidocs/
+/*.iml
+*.html
--- /dev/null
+# Sixth 3D Engine - Quick Reference
+
+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..."
+
+| 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)` |
+
+---
+
+# Code Examples
+
+## Basic Scene Setup
+
+```java
+import eu.svjatoslav.sixth.e3d.gui.ViewFrame;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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(...);
+```
+
+## Creating Shapes
+
+```java
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.*;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.*;
+import eu.svjatoslav.sixth.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
+));
+```
+
+## Custom Composite Shape
+
+```java
+import eu.svjatoslav.sixth.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);
+```
+
+## Lighting and Shading
+
+```java
+import eu.svjatoslav.sixth.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);
+```
+
+## CSG Operations (Boolean Operations)
+
+```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);
+```
+
+## Text in 3D
+
+```java
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.sixth.e3d.gui.TextPointer;
+import eu.svjatoslav.sixth.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');
+```
+
+## Animation with FrameListener
+
+```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
+});
+```
+
+## Camera Control
+
+```java
+import eu.svjatoslav.sixth.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));
+```
+
+## Mouse Interaction on Shapes
+
+```java
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.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);
+```
+
+---
+
+# Class Catalog
+
+## 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
+
+## Coordinate System (CRITICAL)
+
+Sixth 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
+
+```
+AbstractShape (base)
+ ├── AbstractCoordinateShape (has vertices)
+ │ ├── Line
+ │ ├── SolidPolygon
+ │ ├── TexturedTriangle
+ │ ├── Billboard
+ │ └── GlowingPoint
+ └── AbstractCompositeShape (groups shapes)
+ ├── Wireframe shapes (WireframeCube, WireframeSphere, ...)
+ ├── Solid shapes (SolidPolygonCube, SolidPolygonSphere, ...)
+ └── TextCanvas
+```
+
+## Render Pipeline
+
+```
+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
+```
+
+- 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
+
+```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
+```
+
+Test files: `src/test/java/` (JUnit 4)
+
+---
+
+# Tips for AI Agents
+
+1. **Always use project Color:** `eu.svjatoslav.sixth.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)
+
+| 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 |
--- /dev/null
+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.
--- /dev/null
+* Documentation
+** Document boolean operations
+
+* 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:
+
+** Allow manual thread count specification in performance test demo
+:PROPERTIES:
+:CUSTOM_ID: allow-manual-thread-count-specification
+:END:
+By default, suggest using half of the available CPU cores.
+
+** Rename shaded polygon demo to "Shape Gallery" or "Shape Library"
+:PROPERTIES:
+:CUSTOM_ID: rename-shaded-polygon-demo
+:END:
+Extend it to display all available primitive shapes with labels,
+documenting each shape and its parameters.
+
+* Performance
+:PROPERTIES:
+:CUSTOM_ID: performance
+:END:
+** Benchmark optimal CPU core count
+:PROPERTIES:
+:CUSTOM_ID: benchmark-optimal-cpu-core-count
+:END:
+Determine the ideal number of threads for rendering.
+
+** Autodetect optimal thread count
+:PROPERTIES:
+:CUSTOM_ID: autodetect-optimal-thread-count
+:END:
+Use half of available cores by default, but benchmark first to find
+the sweet spot.
+
+** Dynamically resize horizontal per-CPU core slices based on their complexity
+
++ Some slices have more details than others. So some are rendered
+ faster than others. It would be nice to balance rendering load
+ evenly across all CPU cores.
+
+** 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.
+
+* Features
+:PROPERTIES:
+:CUSTOM_ID: features
+:END:
+** Ensure that current quaternions math is optimal
+:PROPERTIES:
+:CUSTOM_ID: add-quaternion-math
+:END:
+
++ add tree demo where branches are moving
+
+** Add polygon reduction based on view distance (LOD)
+:PROPERTIES:
+:CUSTOM_ID: add-polygon-reduction-lod
+:END:
+
+** 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 support for constructive solid geometry (CSG) boolean operations
+:PROPERTIES:
+:CUSTOM_ID: add-csg-support
+:END:
+
+** Add shadow casting
+:PROPERTIES:
+:CUSTOM_ID: add-shadow-casting
+:END:
+
++ Note: Maybe skip this and go straight for: [[id:bcea8a81-9a9d-4daa-a273-3cf4340b769b][raytraced global
+ illumination]].
+
+Study how shadows are done. Evaluate realtime shadows vs pre-baked
+shadows.
+
+** Add raytraced global illumination support
+:PROPERTIES:
+:ID: bcea8a81-9a9d-4daa-a273-3cf4340b769b
+:END:
+
+- Raytracing must have configurable ray bounce count.
+- Raytracing results should be cached and cache must be updated
+ on-demand or when light sources or geometry changes.
+
+** 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.
+
+** Make it possible to configure field of view (FOV)
+** Explore possibility for implementing better perspective correct textured polygons
+
+** Add X, Y, Z axis indicators
+Will use different colored arrows + text label
+
+** 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 Sixth parent project.
+ - Consider integrating with FriCAS or similar CAS software so that
+ formula parsing and computation happens there.
+
++ Study and apply where applicable
+:PROPERTIES:
+:CUSTOM_ID: study-and-apply
+:END:
++ 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/
+
+* Sixth 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.
+
+** Math graphs demo
+
++ Instead of projecting 2D visualizations onto 3D space, visualize
+ some formula using all 3 dimensions available.
--- /dev/null
+#!/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
--- /dev/null
+#!/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/sixth-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "All classes" -t png -ho
+ javainspect -j target/sixth-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "GUI" -t png -w "eu.svjatoslav.sixth.e3d.gui.*" -ho
+ javainspect -j target/sixth-3d-*-SNAPSHOT.jar -d doc/graphs/ -n "Raster engine" -t png -w "eu.svjatoslav.sixth.e3d.renderer.raster.*" -ho
+
+ meviz index -w doc/graphs/ -t "Sixth 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/sixth-3d/
+
+if [ $? -eq 0 ]; then
+ echo "✓ Upload completed successfully!"
+else
+ echo "✗ Upload failed!"
+fi
+
+echo ""
+echo "Press ENTER to close this window."
+read
--- /dev/null
+<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
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Constructive Solid Geometry - Sixth 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.org][Back to main documentation]]
+
+* 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/sixth-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/sixth-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(origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(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(origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(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(origin(), 80, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(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 both, combine, invert 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 Sixth 3D
+:PROPERTIES:
+:CUSTOM_ID: using-csg-in-sixth-3d
+:END:
+
+CSG operations are methods on [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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.sixth.e3d.renderer.raster.shapes.composite.solid.*;
+
+// Create two shapes
+SolidPolygonCube cube = new SolidPolygonCube(origin(), 100, Color.GREEN);
+SolidPolygonSphere sphere = new SolidPolygonSphere(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* children. Other children are
+preserved:
+
+| 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 | Preserved | Preserved | Preserved |
+| Nested composite | Preserved unchanged | Preserved unchanged | Preserved unchanged |
+
+This allows you to attach labels, decorations, or wireframe overlays to
+shapes without them being affected by CSG operations.
+
+** 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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/BspTree.html][BspTree]] | BSP tree for spatial partitioning and CSG operations |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html][Plane]] | Partitioning plane used by BSP nodes |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape processed by CSG |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Base class with union/subtract/intersect methods |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.html][SolidPolygonMesh]] | Custom polygon mesh for arbitrary geometry |
+| SolidPolygon* primitives | See [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/#shape-gallery][Shape Gallery]] for all available shapes |
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Frustum & View Frustum Culling - Sixth 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.org][Back to main documentation]]
+
+* 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 Sixth 3D
+:PROPERTIES:
+:CUSTOM_ID: frustum-culling-implementation
+:END:
+
+Frustum culling is applied automatically to all [[../index.org#mesh][composite shapes]]
+during Phase 2 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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Frustum.html#intersectsAABB][intersectsAABB()]]
+ - If outside: skip the entire composite and all children
+ - If inside: continue transforming children
+
+** 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.add(buildingWalls);
+ buildingComposite.add(buildingRoof);
+ buildingComposite.add(buildingInterior);
+ cityBlock.add(buildingComposite);
+}
+
+// Less effective: Everything in one giant composite
+AbstractCompositeShape allObjects = new AbstractCompositeShape();
+allObjects.add(building1Walls);
+allObjects.add(building1Roof);
+allObjects.add(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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractShape.html#updateBoundingBox()][updateBoundingBox()]] after moving shapes
+
+* Technical Details
+:PROPERTIES:
+:CUSTOM_ID: frustum-technical-details
+:END:
+
+The [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html][Hesse normal form]]: (normal vector, distance)
+
+The frustum is updated once per frame in
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/ShapeCollection.html][ShapeCollection.transformAllShapes()]] before processing composite
+shapes.
--- /dev/null
+<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>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Sixth 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]]
+
+*Sixth 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".
+
+*Sixth 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.
+
+*Sixth 3D* is part of the larger [[https://www3.svjatoslav.eu/projects/sixth/][Sixth 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/sixth-3d-demos/][demos]] for examples of what it can do today.
+
+* How take engine into use
+:PROPERTIES:
+:CUSTOM_ID: taking-engine-into-use
+:END:
+
+Add the *Sixth 3D* dependency to your Maven project:
+
+#+BEGIN_SRC xml
+<dependencies>
+ <dependency>
+ <groupId>eu.svjatoslav</groupId>
+ <artifactId>sixth-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/sixth-3d-demos/][demo applications]] for practical examples. Start with the
+ [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/#minimal-example][minimal example]] to see the basic boilerplate needed to render a 3D
+ scene.
+
+- Study [[#defining-scene][how Sixth 3D engine works]].
+- Read online [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/][JavaDoc]].
+- See [[https://www3.svjatoslav.eu/projects/sixth-3d/graphs/][*Sixth 3D* class diagrams]]. (Diagrams were generated by using
+ [[https://www3.svjatoslav.eu/projects/javainspect/][JavaInspect]] utility)
+
+* Understanding 3D engine
+:PROPERTIES:
+:CUSTOM_ID: defining-scene
+:ID: 4b6c1355-0afe-40c6-86c3-14bf8a11a8d0
+:END:
+
+** 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/sixth-3d/apidocs/eu/svjatoslav/sixth/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]].
+
+** Coordinate System (X, Y, Z)
+:PROPERTIES:
+:CUSTOM_ID: coordinate-system
+:END:
+
+#+INCLUDE: "coordinate-system.svg" export html
+
+*Sixth 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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d-demos/#coordinate-system][sixth-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/sixth-3d/apidocs/eu/svjatoslav/sixth/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.subtract(p2).unit(); // Unit vector from p2 to p1
+Point3D rotated = p1.rotate(new Point3D(0,0,0), Math.PI/4, 0); // Rotate 45° in XZ plane
+#+END_SRC
+
+*** Vertex — Rendering-Ready Coordinates
+
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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=| Position relative to camera (after transform stack) |
+| =onScreenCoordinate= | 2D screen pixels (after perspective projection) |
+| =textureCoordinate= | Optional UV coords in pixel units (not normalized) |
+| =normal= | Optional normal vector for CSG polygon splitting |
+
+During rendering, =calculateLocationRelativeToViewer()= transforms the
+vertex through all spaces: first applying the
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/math/TransformStack.html][TransformStack]] to get =transformedCoordinate=, then projecting to 2D
+for =onScreenCoordinate=. Results are cached per-frame 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 *Sixth 3D*, the [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class implements edges as renderable shapes. Each
+Line connects two [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/math/Vertex.html][Vertex]] endpoints and stores two properties: a
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineAppearance.html][LineAppearance]] factory to ensure consistent styling across
+all edges. The [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.html][WireframeCube]] convenience subclass provides a center-point
+constructor. See the [[https://www3.svjatoslav.eu/projects/sixth-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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] is the primary face type, supporting any number of vertices
+(3 or more). For triangles, it renders directly via scanline rasterization.
+For N-vertex polygons (quads, pentagons, etc.), the engine automatically
+triangulates using fan decomposition before rendering — a quad becomes 2
+triangles, a pentagon becomes 3.
+
+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/sixth-3d/apidocs/eu/svjatoslav/sixth/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);
+#+END_SRC
+
+*** TexturedTriangle — UV-Mapped Faces
+
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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. For large triangles that would show perspective
+distortion, the engine can tessellate the triangle into smaller pieces
+for more accurate rendering.
+
+#+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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]], which handles vertex transformation and depth
+sorting. See the [[https://www3.svjatoslav.eu/projects/sixth-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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#getPlane()][SolidPolygon.getPlane()]] → [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#normal][Plane.normal]] | Lazy-cached once | =Plane= |
+| Per-frame shading | [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]] → =cachedNormal= field | Recomputed every frame | =SolidPolygon= |
+| Lighting calculation | [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html#computeLighting()][LightingManager.computeLighting()]] | Uses normal via =dot(L,N)= | =LightingManager= |
+
+**Implementation notes:**
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html#computeNormal()][Plane.computeNormal()]]: shared zero-allocation helper for computing normals from three points
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Plane.html][Plane]]: stores normals in Hesse normal form (normal + distance) for BSP spatial partitioning
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/math/Vertex.html#normal][Vertex.normal]]: optional field for CSG polygon splitting (not used for rendering)
+
+** Shading & Lighting
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:shading/Shaded%20sphere.png]]
+
+
+*Sixth 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]].
+
+** 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 *Sixth 3D*, meshes are built through composition rather than
+monolithic vertex/index buffers. The [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]] class is
+the foundation for primitive shapes — each instance stores its own
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.html#vertices][List<Vertex>]] directly. This includes [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] (N-vertex
+convex polygons, not limited to triangles), [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]]
+(UV-mapped triangles), and [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] (wireframe edges). The
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.html][SolidPolygonSphere]] generates
+triangles using a latitude-longitude grid: with 16 segments, it
+creates approximately 900 SolidPolygon triangles by looping through
+rings and sectors, calling [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#addShape(eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape)][addShape()]] for each. The generic
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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 in [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#retessellate(eu.svjatoslav.sixth.e3d.gui.RenderingContext)][AbstractCompositeShape.retessellate()]], converting an
+N-vertex polygon into N-2 triangles. Textured polygons undergo
+level-of-detail tessellation — distant triangles are subdivided into
+smaller pieces for perspective-correct texture mapping. 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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/SubShape.html][SubShape]]
+wrappers, allowing [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html#hideGroup(java.lang.String)][showGroup()]] and [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d-demos/#shape-gallery][Shape Gallery demo]] showcases all primitive shapes available in
+*Sixth 3D*, rendered in both wireframe mode (edges only via
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.html][WireframeBox]] and similar) and solid polygon mode (filled surfaces with
+dynamic lighting).
+
+** Perspective correct textures
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:perspective-correct-textures/Affine distortion.png]]
+
+*Sixth 3D* tries to do perspective-correct texture rendering. Read more
+about [[file:perspective-correct-textures/][perspective-correct texture implementation]].
+
+** Frustum & View Frustum Culling
+
+*Sixth 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.]]
+
+** Constructive Solid Geometry
+
+*Sixth 3D* allows performing boolean operations against geometry shapes.
+So one can subtract, unionize or intersect shapes.
+
+#+INCLUDE: "csg/csg-operations.svg" export html
+
+To understand CSG boolean operations, read more about [[file:csg/][Constructive
+Solid Geometry]].
+
+** 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 *Sixth 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 *Sixth 3D*, backface culling is *optional* and disabled by default. Enable it per-shape:
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#setBackfaceCulling(boolean)][SolidPolygon.setBackfaceCulling(true)]]
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html#setBackfaceCulling(boolean)][TexturedTriangle.setBackfaceCulling(true)]]
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d-demos/#winding-order][Winding Order demo]] for an interactive visualization.
+
+** Working with Colors
+:PROPERTIES:
+:CUSTOM_ID: working-with-colors
+:ID: f2c9642a-a093-444f-8992-76c97ff28c16
+:END:
+
+Sixth 3D uses its own [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#r][r]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#g][g]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#b][b]], and
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] class maintains a reusable
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#shadedColor][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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#hex(java.lang.String)][Color.hex(String)]] or the
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#RED][Color.RED]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#GREEN][Color.GREEN]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#BLUE][Color.BLUE]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#YELLOW][Color.YELLOW]],
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#CYAN][Color.CYAN]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#MAGENTA][Color.MAGENTA]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#WHITE][Color.WHITE]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#BLACK][Color.BLACK]], and
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#TRANSPARENT][Color.TRANSPARENT]].
+
+The [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#toAwtColor()][toAwtColor()]] method converts a
+Sixth 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/sixth-3d/apidocs/eu/svjatoslav/sixth/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.sixth.e3d.renderer.raster.Color;
+import static eu.svjatoslav.sixth.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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/Color.html#isTransparent()][isTransparent()]], which returns true when alpha
+equals zero.
+
+For lighting calculations, the [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html#setAmbientLight(eu.svjatoslav.sixth.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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class, where the color field determines the
+line's appearance.
+
+* 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.png]]
+
+This debugging interface helps you understand what the engine is doing
+internally and diagnose rendering issues.
+
+** Show polygon borders
+:PROPERTIES:
+:CUSTOM_ID: show-polygon-borders
+:END:
+
+When enabled, each [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] draws yellow outlines around its
+three edges after rendering its texture content. This overlays the
+geometric structure of tessellated textured surfaces onto the final
+image.
+
+The feature exists primarily to visualize [[file:perspective-correct-textures/index.org::#visualizing-tessellation][adaptive tessellation]] — the
+recursive subdivision of large triangles into smaller pieces for
+perspective-correct texture mapping. When you enable this option on a
+scene with tessellation active, you can see how the
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.html][TexturedPolygonTessellator]] has split each original triangle. Dense
+yellow lines indicate high tessellation (many small triangles), while
+sparse lines show low tessellation or triangles that were small enough
+to render without subdivision.
+
+Use this visualization when investigating:
+- Tessellation behavior: verify that near surfaces are subdivided more
+ densely
+- Texture distortion: compare the tessellation density against visible
+ warping in the texture
+
+** Render alternate segments (overdraw debug)
+:PROPERTIES:
+:CUSTOM_ID: render-alternate-segments
+:END:
+
+Renders only even-numbered horizontal segments (0, 2, 4, 6) while
+leaving odd segments (1, 3, 5, 7) black.
+
+The engine divides the screen into 8 horizontal segments for parallel
+multi-threaded rendering. This toggle helps detect overdraw (threads writing outside their allocated segment).
+
+If you see rendering artifacts in the black segments, it indicates
+that threads are writing pixels outside their assigned area — a clear
+sign of a bug.
+
+** Show segment boundaries
+:PROPERTIES:
+:CUSTOM_ID: show-segment-boundaries
+:END:
+
+Draws visible lines between horizontal rendering segments to show where
+the screen is divided for parallel multi-threaded rendering.
+
+The engine divides the screen into 8 horizontal segments for parallel
+rendering. This toggle draws boundary lines between segments, making it
+easy to see exactly where each thread's rendered area begins and ends.
+
+Useful for:
+- Verifying correct segment division
+- Debugging segment-related rendering issues
+- 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
+
+** Frustum culling statistics
+:PROPERTIES:
+:CUSTOM_ID: frustum-culling-statistics
+:END:
+
+Shows real-time statistics about composite shape frustum culling
+efficiency:
+
+| 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%) |
+
+*What is frustum culling?*
+
+Frustum culling is an optimization that skips rendering objects outside
+the camera's view. Before rendering each composite shape, the engine
+tests its bounding box against the view frustum. If the bounding box is
+completely outside the visible area, the entire composite (and all its
+children) are skipped.
+
+*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.
+
+** 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 500ms 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.
+
+* 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=sixth-3d.git;a=snapshot;h=HEAD;sf=tgz][Download latest source code snapshot in TAR GZ format]]
+- [[https://www2.svjatoslav.eu/gitweb/?p=sixth-3d.git;a=summary][Browse Git repository online]]
+- Clone Git repository using command:
+ : git clone https://www3.svjatoslav.eu/git/sixth-3d.git
--- /dev/null
+<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>
--- /dev/null
+<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>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Perspective-Correct Textures - Sixth 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.org][Back to main documentation]]
+
+* 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 *Sixth 3D* engine solves this through *adaptive polygon tessellation*.
+Instead of computing true perspective-correct interpolation per pixel
+(which is expensive), the engine subdivides large triangles into
+smaller pieces. Each sub-triangle is rendered with simple affine
+interpolation, but because the pieces are small, the error is
+negligible.
+
+* How Tessellation Works
+:PROPERTIES:
+:CUSTOM_ID: how-tessellation-works
+:END:
+
+The engine uses *screen-space adaptive tessellation* via
+[[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/ScreenSpaceTessellator.html][ScreenSpaceTessellator]]:
+
+#+BEGIN_SRC java
+void tessellate(TexturedTriangle polygon) {
+ // Find the longest edge in screen-space pixels
+ ScreenSpaceEdge longest = findLongestScreenEdge(polygon);
+
+ if (longest.screenLength <= maxScreenPixels) {
+ // Small enough on screen: add to result
+ result.add(polygon);
+ } else {
+ // Split at midpoint (interpolate world, texture, screen coords)
+ Vertex middle = longest.interpolateMidpoint();
+ // Recurse on two sub-triangles
+ tessellate(subTriangle1);
+ tessellate(subTriangle2);
+ }
+}
+#+END_SRC
+
+#+INCLUDE: "triangle-tessellation.svg" export html
+
+The midpoint is computed by averaging both 3D coordinates, texture
+coordinates, *and* screen coordinates. This ensures each sub-triangle
+has correct depth sorting.
+
+** Adaptive Threshold
+
+The tessellation threshold is *adaptive* - it adjusts dynamically based
+on polygon budget:
+
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/AdaptiveTessellationController.html][AdaptiveTessellationController]] tracks polygon count per frame
+- If count exceeds ~1000 polygons, threshold increases (less tessellation)
+- If count is low and threshold above minimum, threshold decreases (better quality)
+- Threshold range: 30-500 pixels
+
+** Safeguards Against Excessive Tessellation
+
+When the camera is very close to a polygon, screen-space coordinates
+can become extremely large (100,000+ pixels). Without safeguards, this
+would produce millions of triangles. The tessellator includes three
+limits:
+
+- Maximum recursion depth (8 levels = max 256 triangles per polygon)
+- Maximum triangles per polygon (hard limit of 200)
+- Maximum edge length for tessellation (10,000 pixels - skip tessellation for huge polygons)
+
+* Visualizing the Tessellation
+:PROPERTIES:
+:CUSTOM_ID: visualizing-tessellation
+:ID: f4e03f70-c84c-4eaa-90e2-e7d87a68e517
+:END:
+
+Press *F12* to open [[file:~/data/sync2/workspace/sixth-3d/doc/index.org::#developer-tools][Developer tools]] and enable "Show polygon borders".
+This draws yellow outlines around all textured polygons, making the
+tessellation visible:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Slices.png]]
+
+This visualization helps you:
+- Verify tessellation is working correctly
+- See how subdivision density varies with camera distance to the polygon
+- Debug texture distortion issues
+
+* Related Classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Purpose |
+|-----------------+--------------------------------------|
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] | Textured triangle shape |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/ScreenSpaceTessellator.html][ScreenSpaceTessellator]] | Screen-space tessellation for perspective correction |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/AdaptiveTessellationController.html][AdaptiveTessellationController]] | Dynamic threshold adjustment based on polygon budget |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/texture/Texture.html][Texture]] | Mipmap container with Graphics2D |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/texture/TextureBitmap.html][TextureBitmap]] | Raw pixel array for one mipmap level |
--- /dev/null
+<svg viewBox="0 0 520 300" width="1000" height="600">
+ <defs>
+ <marker id="arrow-cyan" viewBox="0 0 10 10" refX="10" 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="520" height="300" fill="#061018"/>
+
+ <!-- Step 1: original triangle -->
+ <text x="80" y="22" fill="#2070c0" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">1. Original</text>
+ <polygon points="80,40 20,170 140,170"
+ fill="rgba(32,112,192,0.12)" stroke="#2070c0" stroke-width="1.5"/>
+ <circle cx="80" cy="40" r="3" fill="#2070c0"/>
+ <circle cx="20" cy="170" r="3" fill="#2070c0"/>
+ <circle cx="140" cy="170" r="3" fill="#2070c0"/>
+ <text x="66" y="36" fill="#ccc" font-size="9" font-family="monospace">A</text>
+ <text x="6" y="184" fill="#ccc" font-size="9" font-family="monospace">B</text>
+ <text x="144" y="184" fill="#ccc" font-size="9" font-family="monospace">C</text>
+
+ <!-- Longest edge highlight -->
+ <line x1="20" y1="170" x2="140" y2="170" stroke="#40b0d0" stroke-width="2.5"/>
+ <text x="80" y="192" fill="#40b0d0" font-size="9" font-weight="700" font-family="monospace" text-anchor="middle">longest edge</text>
+
+ <!-- Arrow to step 2 -->
+ <line x1="156" y1="105" x2="178" y2="105" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#arrow-cyan)"/>
+
+ <!-- Step 2: first split -->
+ <text x="270" y="22" fill="#2070c0" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">2. Split</text>
+
+ <!-- Sub-triangle left -->
+ <polygon points="270,40 210,170 270,170"
+ fill="rgba(32,112,192,0.10)" stroke="#2070c0" stroke-width="1"/>
+ <!-- Sub-triangle right -->
+ <polygon points="270,40 270,170 330,170"
+ fill="rgba(48,160,80,0.10)" stroke="#30a050" stroke-width="1"/>
+
+ <circle cx="270" cy="40" r="3" fill="#2070c0"/>
+ <circle cx="210" cy="170" r="3" fill="#2070c0"/>
+ <circle cx="330" cy="170" r="3" fill="#2070c0"/>
+
+ <!-- Midpoint -->
+ <circle cx="270" cy="170" r="4" fill="#40b0d0"/>
+ <text x="250" y="192" fill="#40b0d0" font-size="9" font-weight="700" font-family="monospace" text-anchor="middle">M</text>
+ <text x="250" y="204" fill="#ccc" font-size="8" font-family="monospace" text-anchor="middle">midpoint</text>
+
+ <!-- Split line -->
+ <line x1="270" y1="40" x2="270" y2="170" stroke="#40b0d0" stroke-width="1.5" stroke-dasharray="4 3"/>
+
+ <!-- Arrow to step 3 -->
+ <line x1="346" y1="105" x2="368" y2="105" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#arrow-cyan)"/>
+
+ <!-- Step 3: fully subdivided -->
+ <text x="440" y="22" fill="#2070c0" font-size="12" font-weight="700" font-family="monospace" text-anchor="middle">3. Recurse</text>
+
+ <!-- Four sub-triangles (A=440,40 B=380,170 C=500,170 M=mid(BC)=440,170 P=mid(AB)=410,105 Q=mid(AC)=470,105) -->
+ <polygon points="440,40 410,105 440,170"
+ fill="rgba(32,112,192,0.12)" stroke="#2070c0" stroke-width="0.8"/>
+ <polygon points="440,40 470,105 440,170"
+ fill="rgba(48,160,80,0.10)" stroke="#30a050" stroke-width="0.8"/>
+ <polygon points="410,105 380,170 440,170"
+ fill="rgba(176,144,32,0.10)" stroke="#b09020" stroke-width="0.8"/>
+ <polygon points="470,105 500,170 440,170"
+ fill="rgba(192,80,136,0.10)" stroke="#c05088" stroke-width="0.8"/>
+
+ <!-- Split lines -->
+ <line x1="440" y1="40" x2="440" y2="170" stroke="#40b0d0" stroke-width="1" stroke-dasharray="3 2"/>
+ <line x1="410" y1="105" x2="440" y2="170" stroke="rgba(64,176,208,0.4)" stroke-width="0.8" stroke-dasharray="3 2"/>
+ <line x1="470" y1="105" x2="440" y2="170" stroke="rgba(64,176,208,0.4)" stroke-width="0.8" stroke-dasharray="3 2"/>
+
+ <!-- Original vertices -->
+ <circle cx="440" cy="40" r="2.5" fill="#2070c0"/>
+ <circle cx="380" cy="170" r="2.5" fill="#2070c0"/>
+ <circle cx="500" cy="170" r="2.5" fill="#2070c0"/>
+ <!-- Midpoints -->
+ <circle cx="440" cy="170" r="3" fill="#40b0d0"/>
+ <circle cx="410" cy="105" r="3" fill="#40b0d0"/>
+ <circle cx="470" cy="105" r="3" fill="#40b0d0"/>
+
+ <!-- Annotation -->
+ <text x="260" y="240" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">Each split halves the longest edge at its midpoint.</text>
+ <text x="260" y="256" fill="#ccc" font-size="10" font-family="monospace" text-anchor="middle">Recursion stops when all edges < maxDistance.</text>
+
+ <!-- Legend -->
+ <circle cx="160" cy="280" r="3" fill="#40b0d0"/>
+ <text x="170" y="284" fill="#40b0d0" font-size="9" font-family="monospace">midpoint (3D + UV averaged)</text>
+</svg>
--- /dev/null
+<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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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>
--- /dev/null
+<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>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Rendering Loop - Sixth 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.org][Back to main documentation]]
+
+* 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]]) |
+| Sort | Unordered shapes | Ordered by depth | Ensure correct visibility (far objects painted first) |
+| Paint | Sorted shapes | Pixels in buffer | Clear segments (parallel) and rasterize triangles into pixel array |
+| Blit | Pixel buffer | Screen image | Copy completed frame to display |
+
+This pipeline runs repeatedly, typically 60 times per second (60 FPS).
+Even if nothing moves, the loop continues running—but the engine [[#frame-listeners][skips
+unnecessary work]] when the scene is static.
+
+** Main loop structure
+:PROPERTIES:
+:CUSTOM_ID: main-loop-structure
+:END:
+
+The render thread runs continuously in a dedicated daemon thread:
+
+#+BEGIN_SRC java
+while (renderThreadRunning) {
+ ensureThatViewIsUpToDate(); // Render one frame
+ maintainTargetFps(); // Sleep if needed
+}
+#+END_SRC
+
+The thread is a daemon, so it automatically stops when the JVM exits.
+You can stop it explicitly with [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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. Useful for benchmarking.
+
+* Rendering phases
+:PROPERTIES:
+:CUSTOM_ID: rendering-phases
+:END:
+
+Each frame goes through 5 phases:
+
+** 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. For each shape:
+ - Apply camera transform
+ - Project 3D → 2D (perspective projection)
+ - Calculate =onScreenZ= for depth 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.
+
+This is single-threaded but very fast — just math, no pixel operations.
+
+** Phase 2: Sort shapes
+:PROPERTIES:
+:CUSTOM_ID: phase-2-sort-shapes
+:END:
+
+Shapes are sorted by =onScreenZ= (depth) in descending order:
+
+#+BEGIN_SRC java
+Collections.sort(queuedShapes, (a, b) -> Double.compare(b.onScreenZ, a.onScreenZ));
+#+END_SRC
+
+*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: "painters-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 =onScreenZ= value represents distance from the camera after
+transformation. Larger values = further away.
+
+** Phase 3: Clear and paint segments (multi-threaded)
+:PROPERTIES:
+:CUSTOM_ID: phase-3-clear-paint-segments
+:END:
+
+The screen is divided into 8 horizontal segments, each processed by a separate thread. Each thread performs two operations:
+
+1. *Clear segment*: Fill its Y-range with background color
+2. *Paint shapes*: Render all shapes within that Y-range
+
+Both operations happen within the same thread task, ensuring clearing completes before painting begins. This provides greater RAM bandwidth utilization compared to single-threaded clearing.
+
+#+INCLUDE: "paint-segments.svg" export html
+
+Each thread:
+- Gets a =SegmentRenderingContext= with Y-bounds (minY, maxY)
+- Clears its segment to background color (parallel memory fill)
+- Iterates all shapes and paints pixels within its Y-range
+- Clips triangles/lines at segment boundaries
+- Detects mouse hits (before clipping)
+
+A =CountDownLatch= waits for all 8 threads to complete before proceeding.
+
+**Why 8 segments?** This matches the typical core count of modern CPUs.
+The fixed thread pool (=Executors.newFixedThreadPool(8)=) avoids the
+overhead of creating threads per frame.
+
+**Why parallel clearing?** Each thread clears its own memory region
+(disjoint Y-ranges), avoiding synchronization overhead while maximizing
+memory bandwidth utilization on multi-core systems.
+
+** Phase 4: Combine mouse results
+:PROPERTIES:
+:CUSTOM_ID: phase-4-combine-mouse-results
+:END:
+
+During painting, each segment tracks which shape is under the mouse cursor.
+Since all segments paint the same shapes (just different Y-ranges), they
+should all report the same hit. Phase 4 takes the first non-null result:
+
+#+BEGIN_SRC java
+for (SegmentRenderingContext ctx : segmentContexts) {
+ if (ctx.getSegmentMouseHit() != null) {
+ renderingContext.setCurrentObjectUnderMouseCursor(ctx.getSegmentMouseHit());
+ break;
+ }
+}
+#+END_SRC
+
+** Phase 5: Blit to screen
+:PROPERTIES:
+:CUSTOM_ID: phase-5-blit-to-screen
+:END:
+
+The rendered =BufferedImage= is copied 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(renderingContext.bufferedImage, 0, 0, null);
+ g.dispose();
+} while (bufferStrategy.contentsRestored());
+
+bufferStrategy.show();
+Toolkit.getDefaultToolkit().sync();
+#+END_SRC
+
+*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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/ViewPanel.html#addFrameListener(eu.svjatoslav.sixth.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 velocity is zero* (built-in Camera listener returns false when not moving)
+- *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.
+
+#+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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/Camera.html][Camera]] — returns true when user is actively navigating (velocity > 0)
+- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/RenderingContext.html][RenderingContext]] holds all state for a single frame:
+
+| Field | Purpose |
+|-------+---------|
+| =pixels[]= | Raw pixel buffer (int[] in RGB format) |
+| =bufferedImage= | Java2D wrapper around pixels |
+| =graphics= | Graphics2D for text, lines, shapes |
+| =width=, =height= | Rendering area dimensions |
+| =centerCoordinate= | Screen center (for projection) |
+| =projectionScale= | Perspective scale factor |
+| =frameNumber= | Monotonically increasing frame counter |
+
+A new context is created when the window is resized. Otherwise, the
+same context is reused — =prepareForNewFrameRendering()= just resets
+per-frame state like mouse tracking.
--- /dev/null
+<svg viewBox="0 0 400 200" width="400" height="200">
+ <rect width="400" height="200" fill="#061018"/>
+ <rect x="10" y="5" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="30" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="55" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="80" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="105" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="130" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="155" width="380" height="22" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <rect x="10" y="180" width="380" height="15" fill="#1a3a4a" stroke="#30a050" stroke-width="1"/>
+ <text x="20" y="21" fill="#30a050" font-size="11" font-family="monospace">Segment 0 (Thread 0)</text>
+ <text x="20" y="46" fill="#30a050" font-size="11" font-family="monospace">Segment 1 (Thread 1)</text>
+ <text x="20" y="71" fill="#30a050" font-size="11" font-family="monospace">Segment 2 (Thread 2)</text>
+ <text x="20" y="96" fill="#30a050" font-size="11" font-family="monospace">Segment 3 (Thread 3)</text>
+ <text x="20" y="121" fill="#30a050" font-size="11" font-family="monospace">Segment 4 (Thread 4)</text>
+ <text x="20" y="146" fill="#30a050" font-size="11" font-family="monospace">Segment 5 (Thread 5)</text>
+ <text x="20" y="171" fill="#30a050" font-size="11" font-family="monospace">Segment 6 (Thread 6)</text>
+ <text x="20" y="193" fill="#30a050" font-size="11" font-family="monospace">Segment 7 (Thread 7)</text>
+</svg>
--- /dev/null
+
+<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>
--- /dev/null
+<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="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="140" y="25" width="90" height="30" rx="3" fill="rgba(32,112,192,0.15)" stroke="#2070c0" stroke-width="1.5"/>
+ <text x="185" y="43" fill="#2070c0" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Transform</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>
+
+ <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>
+
+ <rect x="570" y="25" width="35" height="30" rx="3" fill="rgba(100,100,100,0.2)" stroke="#aaa" stroke-width="1.5"/>
+ <text x="587" y="43" fill="#aaa" font-size="10" font-weight="700" font-family="monospace" text-anchor="middle">Screen</text>
+
+ <!-- Arrows -->
+ <line x1="105" y1="40" x2="135" y2="40" stroke="#30a050" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+ <line x1="230" y1="40" x2="260" y2="40" stroke="#2070c0" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+ <line x1="335" y1="40" x2="360" y2="40" stroke="#c05088" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+ <line x1="445" y1="40" x2="475" y2="40" stroke="#FF6600" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+ <line x1="540" y1="40" x2="565" y2="40" stroke="#39FF14" stroke-width="1.5" marker-end="url(#arrowhead)"/>
+
+ <!-- Labels below -->
+ <text x="60" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">3D vertices</text>
+ <text x="185" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">world→screen</text>
+ <text x="300" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">back-to-front</text>
+ <text x="405" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">8 threads</text>
+ <text x="510" y="67" fill="#666" font-size="8" font-family="monospace" text-anchor="middle">copy buffer</text>
+</svg>
--- /dev/null
+<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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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
--- /dev/null
+<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 Sixth 3D</text>
+</svg>
--- /dev/null
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Shading & Lighting - Sixth 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.org][Back to main documentation]]
+
+* Overview
+:PROPERTIES:
+:CUSTOM_ID: shading-lighting
+:END:
+
+#+attr_html: :class responsive-img
+#+attr_latex: :width 1000px
+[[file:Shaded sphere.png]]
+
+*Sixth 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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] computes a unit vector pointing from the polygon's center
+to each [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html#computeLighting()][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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/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. Computing lighting
+during this single-threaded phase ensures thread safety and allows the
+result to be cached in the polygon's reusable [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html#shadedColor][shadedColor]] field, avoiding
+memory allocation 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.sixth.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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 Sixth 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)
+- 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.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.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.sixth.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/sixth-3d/apidocs/eu/svjatoslav/sixth/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/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.html][LightingManager]] | Manages light sources and computes shading |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.html][LightSource]] | Individual light with position, color, intensity |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]] | Polygon shape with shading support |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]] | Composite shape with shading propagation |
+| [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/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 2* (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/sixth-3d/apidocs/eu/svjatoslav/sixth/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?**
+
+- Transform phase is *single-threaded* — no race conditions
+- Lighting computed *once per polygon per frame* — not per pixel
+- Result reused during multi-threaded paint phase — efficient
+
+** Performance Characteristics
+:PROPERTIES:
+:CUSTOM_ID: performance
+:END:
+
+| Aspect | Cost |
+|--------------+-------------------------------|
+| Computation | Per polygon, not per pixel |
+| Phase | Single-threaded (transform) |
+| 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*: Single-threaded transform phase avoids synchronization
+- *Pre-computed*: All 8 paint threads use 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.
--- /dev/null
+<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">θ > 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 < 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>
--- /dev/null
+<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>
--- /dev/null
+.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
--- /dev/null
+<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>
--- /dev/null
+<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>sixth-3d</artifactId>
+ <version>1.5-SNAPSHOT</version>
+ <name>Sixth 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>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/sixth-3d.git</connection>
+ <developerConnection>scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/sixth-3d.git</developerConnection>
+ <tag>HEAD</tag>
+ </scm>
+
+</project>
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
+ );
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.geometry;
+
+import eu.svjatoslav.sixth.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.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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() {
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.geometry;
+
+import eu.svjatoslav.sixth.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 Sixth 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
+
+ // Sixth 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.geometry;
+
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.geometry;
+
+import eu.svjatoslav.sixth.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 Sixth 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.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/**
+ * Provides basic geometry classes for 2D and 3D coordinates and shapes.
+ *
+ * @see eu.svjatoslav.sixth.e3d.geometry.Point2D
+ * @see eu.svjatoslav.sixth.e3d.geometry.Point3D
+ */
+package eu.svjatoslav.sixth.e3d.geometry;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.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.sixth.e3d.gui.humaninput.WorldNavigationUserInputTracker}),
+ * and the mouse controls the look direction (handled by
+ * {@link eu.svjatoslav.sixth.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.sixth.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;
+ /**
+ * Just in case we want to adjust global speed for some reason.
+ */
+ private 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+/**
+ * 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>Displayed in the {@link DeveloperToolsPanel} to help developers understand
+ * culling efficiency and optimize scene graphs.</p>
+ *
+ * @see DeveloperToolsPanel
+ * @see eu.svjatoslav.sixth.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 int totalComposites = 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 int culledComposites = 0;
+
+ /**
+ * Resets all statistics to zero.
+ * Called at the start of each frame before computing new statistics.
+ */
+ public void reset() {
+ totalComposites = 0;
+ culledComposites = 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() {
+ if (totalComposites == 0) {
+ return 0.0;
+ }
+ return 100.0 * culledComposites / totalComposites;
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.AdaptiveTessellationController;
+
+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>Adaptive tessellation statistics (threshold and polygon count)</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
+ * @see AdaptiveTessellationController
+ */
+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 tessellation threshold.
+ */
+ private final JLabel tessellationThresholdLabel;
+ /**
+ * The label showing tessellated polygon count.
+ */
+ private final JLabel tessellationPolygonCountLabel;
+ /**
+ * The label showing current render thread count.
+ */
+ private final JLabel renderThreadsLabel;
+ /**
+ * 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 tessellation statistics labels
+ tessellationThresholdLabel = new JLabel("30.0");
+ tessellationThresholdLabel.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
+ tessellationPolygonCountLabel = new JLabel("0");
+ tessellationPolygonCountLabel.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));
+
+ final JPanel topPanel = new JPanel();
+ topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS));
+ topPanel.add(createSettingsPanel());
+ topPanel.add(createCameraPanel());
+ topPanel.add(createCullingPanel());
+ topPanel.add(createTessellationPanel());
+ topPanel.add(createRenderThreadsPanel());
+ 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);
+ }
+ }
+ });
+ panel.add(copyButton, BorderLayout.EAST);
+
+ return panel;
+ }
+
+ 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 createTessellationPanel() {
+ 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("Adaptive Tessellation")
+ ));
+
+ // Single row: threshold, polygon count
+ final JPanel statsRow = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 2));
+ statsRow.add(new JLabel("Threshold (px):"));
+ statsRow.add(tessellationThresholdLabel);
+ statsRow.add(new JLabel(" Polygons:"));
+ statsRow.add(tessellationPolygonCountLabel);
+
+ 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 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("");
+ }
+ });
+
+ panel.add(clearButton);
+
+ return panel;
+ }
+
+ private void updateDisplay() {
+ if (updating) {
+ return;
+ }
+ updating = true;
+ try {
+ updateCameraLabel();
+ updateCullingStatistics();
+ updateTessellationStatistics();
+ updateRenderThreadsLabel();
+ updateLogDisplay();
+ } 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));
+ culledCompositesLabel.setText(String.valueOf(stats.culledComposites));
+ culledPercentLabel.setText(String.format(" (%.1f%%)", stats.getCulledPercentage()));
+ }
+
+ private void updateTessellationStatistics() {
+ final AdaptiveTessellationController controller = AdaptiveTessellationController.getInstance();
+ tessellationThresholdLabel.setText(String.format("%.1f", controller.getThreshold()));
+ tessellationPolygonCountLabel.setText(String.valueOf(controller.getLastPolygonCount()));
+ }
+
+ private void updateRenderThreadsLabel() {
+ if (viewPanel == null) {
+ return;
+ }
+ renderThreadsLabel.setText(String.valueOf(viewPanel.getNumRenderThreads()));
+ }
+
+ 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Box;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardHelper;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardInputHandler;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.sixth.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.sixth.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.sixth.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.sixth.e3d.gui.humaninput.KeyboardFocusStack manages which component has keyboard focus
+ * @see eu.svjatoslav.sixth.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.sixth.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 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) {
+ return viewPanel.getKeyboardFocusStack().pushFocusOwner(this);
+ }
+
+ @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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Frustum;
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.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.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.sixth.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 for parallel rendering.
+ * Each segment is rendered by a separate thread.
+ * Configurable at runtime via {@link ViewPanel#setNumRenderThreads(int)}.
+ */
+ public final int numRenderSegments;
+
+ /**
+ * 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;
+
+ /**
+ * 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;
+ /**
+ * Number of frame that is currently being rendered.
+ * Every frame has its own number.
+ */
+ public int frameNumber = 0;
+
+ /**
+ * 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;
+ /**
+ * 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;
+
+ /**
+ * 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;
+
+ /**
+ * 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, numRenderSegments);
+ }
+
+ /**
+ * Creates a new rendering context with Y-bounds for segment rendering.
+ *
+ * <p>Initializes the offscreen image buffer, extracts the raw pixel byte array,
+ * and configures anti-aliasing on the Graphics2D context.</p>
+ *
+ * @param width the rendering area width in pixels
+ * @param height the rendering area height in pixels
+ * @param renderMinY minimum Y coordinate (inclusive) to render
+ * @param renderMaxY maximum Y coordinate (exclusive) to render
+ * @param numRenderSegments number of parallel render segments (threads)
+ */
+ public RenderingContext(final int width, final int height,
+ final int renderMinY, final int renderMaxY,
+ final int numRenderSegments) {
+ this.width = width;
+ this.height = height;
+ this.renderMinY = renderMinY;
+ this.renderMaxY = renderMaxY;
+ this.numRenderSegments = numRenderSegments;
+ this.centerCoordinate = new Point2D(width / 2d, height / 2d);
+ this.projectionScale = width / 3d;
+ this.stereoViewportWidth = width;
+ this.stereoViewportOffsetX = 0;
+ this.renderMinX = 0;
+ this.renderMaxX = width;
+
+ bufferedImage = new BufferedImage(width, height, bufferedImageType);
+
+ final WritableRaster raster = bufferedImage.getRaster();
+ final DataBufferInt dbi = (DataBufferInt) raster.getDataBuffer();
+ pixels = dbi.getData();
+
+ 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.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.graphics = parent.graphics;
+ this.developerTools = parent.developerTools;
+ this.debugLogBuffer = parent.debugLogBuffer;
+ this.lightingManager = parent.lightingManager;
+ this.segmentGraphics = null;
+ }
+
+ /**
+ * 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 Y bounds.
+ *
+ * @return array of Graphics2D objects, one per segment
+ */
+ private Graphics2D[] createSegmentGraphics() {
+ final Graphics2D[] contexts = new Graphics2D[numRenderSegments];
+ final int segmentHeight = height / numRenderSegments;
+
+ for (int i = 0; i < numRenderSegments; i++) {
+ final int minY = i * segmentHeight;
+ final int maxY = (i == numRenderSegments - 1) ? height : (i + 1) * segmentHeight;
+
+ final Graphics2D g = bufferedImage.createGraphics();
+ g.setClip(0, minY, width, maxY - minY);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ contexts[i] = g;
+ }
+
+ return contexts;
+ }
+
+ /**
+ * 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) {
+ this.currentObjectUnderMouseCursor = currentObjectUnderMouseCursor;
+ }
+
+ /**
+ * 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);
+ }
+
+ return viewRepaintNeeded;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.sixth.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;
+
+ /**
+ * 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) {
+ this.segmentMouseHit = controller;
+ }
+
+ /**
+ * 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;
+ }
+
+ @Override
+ public synchronized MouseInteractionController getCurrentObjectUnderMouseCursor() {
+ return segmentMouseHit;
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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>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 default 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;
+
+ final GraphicsDevice 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();
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.InputManager;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardFocusStack;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseEvent;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.sixth.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;
+
+/**
+ * AWT Canvas that provides a 3D rendering surface with built-in camera navigation.
+ *
+ * <p>{@code ViewPanel} is the primary entry point for embedding the Sixth 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();
+ /** 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();
+ /** The executor service for parallel rendering. */
+ private ExecutorService renderExecutor = Executors.newFixedThreadPool(Math.min(Runtime.getRuntime().availableProcessors(), 4));
+ /** Number of render threads. Can be changed at runtime via {@link #setNumRenderThreads(int)}. */
+ private volatile int numRenderThreads = Math.min(Runtime.getRuntime().availableProcessors(), 4);
+ /** 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();
+
+ /**
+ * 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;
+
+ /**
+ * 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.
+ */
+ private int targetFPS = 60;
+
+ /**
+ * 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 buffer strategy for page-flipping rendering. */
+ 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);
+
+ keyboardFocusStack = new KeyboardFocusStack(this);
+
+ initializeCanvas();
+
+ // 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 global lighting manager for the scene.
+ * Add light sources here to illuminate the world.
+ *
+ * @return the lighting manager
+ */
+ public LightingManager getLightingManager() {
+ return lightingManager;
+ }
+
+ /**
+ * 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);
+ 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++;
+
+ try {
+ if (stereoModeEnabled) {
+ final int eyeWidth = renderingContext.width / 2;
+ // Left eye
+ renderEye(StereoEye.LEFT, eyeWidth, 0);
+ // Increment frame number so vertices are re-transformed for the right eye
+ // (Vertex cache uses frameNumber to skip redundant transforms)
+ renderingContext.frameNumber++;
+ // Right eye
+ renderEye(StereoEye.RIGHT, eyeWidth, eyeWidth);
+ } else {
+ renderEye(StereoEye.NONE, renderingContext.width, 0);
+ }
+
+ // === Blit loop — only re-blit, never re-render ===
+ // contentsRestored() can trigger when the OS recreates the back buffer
+ // (common during window creation). Since our offscreen bufferedImage still
+ // contains the correct frame data, we only need to re-blit, not re-render.
+ do {
+ Graphics2D g = null;
+ try {
+ g = (Graphics2D) bufferStrategy.getDrawGraphics();
+ if (g != null) {
+ // Use image observer to ensure proper image loading
+ g.drawImage(renderingContext.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());
+
+ if (bufferStrategy.contentsLost()) {
+ debugLogBuffer.log("[VIEWPANEL] Buffer contents LOST, reinitializing");
+ bufferStrategyInitialized = false;
+ bufferStrategy = null;
+ } else {
+ bufferStrategy.show();
+ java.awt.Toolkit.getDefaultToolkit().sync();
+ }
+ } catch (final Exception e) {
+ debugLogBuffer.log("[VIEWPANEL] renderFrame exception: " + e.getMessage());
+ e.printStackTrace();
+ bufferStrategyInitialized = false;
+ bufferStrategy = null;
+ }
+ }
+
+ /**
+ * Clears a single segment's pixel area to the background color.
+ * Called by each render thread before painting shapes.
+ * In stereo mode, only clears the pixels belonging to the current eye's viewport.
+ *
+ * @param ctx the segment rendering context with Y-bounds
+ */
+ private void clearSegmentPixels(final SegmentRenderingContext ctx) {
+ final int rgb = (backgroundColor.r << 16) | (backgroundColor.g << 8) | backgroundColor.b;
+ final int width = renderingContext.width;
+ final int[] pixels = renderingContext.pixels;
+
+ final int minX = ctx.stereoViewportOffsetX;
+ final int maxX = minX + ctx.stereoViewportWidth;
+
+ for (int y = ctx.renderMinY; y < ctx.renderMaxY; y++) {
+ final int rowOffset = y * width;
+ Arrays.fill(pixels, rowOffset + minX, rowOffset + maxX, rgb);
+ }
+ }
+
+ private void combineMouseResults(final SegmentRenderingContext[] segmentContexts) {
+ // 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) {
+ renderingContext.setCurrentObjectUnderMouseCursor(hit);
+ 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 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 = DEFAULT_STEREO_IPD;
+
+ /** 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;
+ }
+
+ /**
+ * Renders a single eye view into the current rendering context.
+ * Temporarily offsets the camera, updates the context for the eye's
+ * viewport, transforms and paints the scene, then restores the camera.
+ *
+ * @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 void renderEye(final StereoEye eye, final int eyeWidth, final int eyeOffsetX) {
+ 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 {
+ renderingContext.stereoEye = eye;
+ renderingContext.stereoViewportWidth = eyeWidth;
+ renderingContext.stereoViewportOffsetX = eyeOffsetX;
+ renderingContext.renderMinX = eyeOffsetX;
+ renderingContext.renderMaxX = eyeOffsetX + eyeWidth;
+ renderingContext.centerCoordinate.x = eyeWidth / 2.0;
+ renderingContext.projectionScale = eyeWidth / 3.0;
+
+ rootShapeCollection.transformShapes(this, renderingContext);
+ rootShapeCollection.sortShapes();
+
+ // Phase 4: Clear and paint segments in parallel
+ final int segments = renderingContext.numRenderSegments;
+ final int height = renderingContext.height;
+ final int segmentHeight = height / segments;
+ final SegmentRenderingContext[] segmentContexts = new SegmentRenderingContext[segments];
+ final CountDownLatch latch = new CountDownLatch(segments);
+
+ for (int i = 0; i < segments; i++) {
+ final int segmentIndex = i;
+ final int minY = i * segmentHeight;
+ final int maxY = (i == segments - 1) ? height : (i + 1) * segmentHeight;
+
+ segmentContexts[i] = new SegmentRenderingContext(renderingContext, minY, maxY, segmentIndex);
+
+ if (developerTools.renderAlternateSegments && (i % 2 == 1)) {
+ latch.countDown();
+ continue;
+ }
+
+ renderExecutor.submit(() -> {
+ try {
+ clearSegmentPixels(segmentContexts[segmentIndex]);
+ rootShapeCollection.paintShapes(segmentContexts[segmentIndex]);
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ // 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, so it can only be in one eye's viewport.
+ // Calling combineMouseResults for the wrong eye would overwrite the
+ // correct hit with null.
+ final MouseEvent mouseEvent = renderingContext.getMouseEvent();
+ final boolean mouseInThisEye = (mouseEvent == null)
+ || (mouseEvent.coordinate.x >= eyeOffsetX
+ && mouseEvent.coordinate.x < eyeOffsetX + eyeWidth);
+ if (mouseInThisEye) {
+ combineMouseResults(segmentContexts);
+ }
+
+ if (developerTools.showSegmentBoundaries) {
+ final int[] pixels = renderingContext.pixels;
+ final int width = renderingContext.width;
+ final int red = (255 << 16);
+ for (int i = 1; i < segments; i++) {
+ final int offset = i * segmentHeight * width;
+ Arrays.fill(pixels, offset, offset + width, red);
+ }
+ }
+ } finally {
+ location.x = originalX;
+ }
+ }
+
+ // ------------------------------------------------------------------
+
+ /**
+ * 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);
+ }
+ }
+ }
+
+ /**
+ * Stops rendering of this view.
+ */
+ public void stop() {
+ renderThreadRunning = false;
+ renderExecutor.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, "RenderThread");
+ renderThread.setDaemon(true);
+ renderThread.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);
+
+ if (viewRepaintNeeded) {
+ viewRepaintNeeded = false;
+ renderFrame = true;
+ }
+
+ // abort rendering if window size is invalid
+ if ((getWidth() > 0) && (getHeight() > 0) && renderFrame) {
+ renderFrame();
+ viewRepaintNeeded = renderingContext.handlePossibleComponentMouseEvent();
+ }
+ }
+
+ private void maintainRenderingContext() {
+ int panelWidth = getWidth();
+ int panelHeight = getHeight();
+
+ if (panelWidth <= 0 || panelHeight <= 0) {
+ if (renderingContext != null) {
+ renderingContext.dispose();
+ renderingContext = null;
+ }
+ return;
+ }
+
+ // create new rendering context if window size has changed OR thread count has changed
+ if ((renderingContext == null)
+ || (renderingContext.width != panelWidth)
+ || (renderingContext.height != panelHeight)
+ || (renderingContext.numRenderSegments != numRenderThreads)) {
+ if (renderingContext != null) {
+ renderingContext.dispose();
+ }
+ renderingContext = new RenderingContext(panelWidth, panelHeight, numRenderThreads);
+ renderingContext.developerTools = developerTools;
+ renderingContext.debugLogBuffer = debugLogBuffer;
+ renderingContext.lightingManager = lightingManager;
+ }
+
+ 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.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.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ */
+public class ViewSpaceTracker {
+
+ private final static int minimalTessellationFactor = 5;
+
+ /**
+ * 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() {
+ return center.transformedCoordinate
+ .getAngleXY(down.transformedCoordinate);
+ }
+
+ /**
+ * Returns the angle between the viewer and object in the XZ plane.
+ *
+ * @return the XZ angle in radians
+ */
+ public double getAngleXZ() {
+ return center.transformedCoordinate
+ .getAngleXZ(right.transformedCoordinate);
+ }
+
+ /**
+ * Returns the angle between the viewer and object in the YZ plane.
+ *
+ * @return the YZ angle in radians
+ */
+ public double getAngleYZ() {
+ return center.transformedCoordinate
+ .getAngleYZ(down.transformedCoordinate);
+ }
+
+ /**
+ * 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() {
+ return center.transformedCoordinate.getVectorLength();
+ }
+
+ /**
+ * Proposes a tessellation factor for texture LOD based on distance to camera.
+ *
+ * @return the proposed tessellation factor
+ */
+ public double proposeTessellationFactor() {
+ final double distanceToCamera = getDistanceToCamera();
+
+ double proposedTessellationFactor = distanceToCamera / 5;
+
+ if (proposedTessellationFactor < minimalTessellationFactor)
+ proposedTessellationFactor = minimalTessellationFactor;
+
+ return proposedTessellationFactor;
+ }
+
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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();
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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();
+
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.gui.Camera;
+import eu.svjatoslav.sixth.e3d.gui.FrameListener;
+import eu.svjatoslav.sixth.e3d.gui.ViewFrame;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.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 wheelMovedDirection = 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 |= handleMouseVerticalScrolling();
+ 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) {
+ // 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) {
+ wheelMovedDirection += evt.getWheelRotation();
+ }
+
+ private boolean handleMouseVerticalScrolling() {
+ final Camera camera = viewPanel.getCamera();
+ final double actualAcceleration = 50 * camera.cameraAcceleration * (1 + (camera.getMovementSpeed() / 10));
+ camera.getMovementVector().y += (wheelMovedDirection * actualAcceleration);
+ camera.enforceSpeedLimit();
+ boolean repaintNeeded = wheelMovedDirection != 0;
+ wheelMovedDirection = 0;
+ return repaintNeeded;
+ }
+
+ 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
+
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+
+import java.util.Stack;
+
+/**
+ * Manages a stack-based keyboard focus system for interactive 3D components.
+ *
+ * <p>The focus stack determines which {@link KeyboardInputHandler} currently receives
+ * keyboard events. When a component gains focus (e.g., by being clicked), it is pushed
+ * onto the stack and the previous focus owner is notified. When the component releases
+ * focus (e.g., pressing ESC), the previous handler is restored.</p>
+ *
+ * <p>The default handler at the bottom of the stack is a
+ * {@link WorldNavigationUserInputTracker}, which handles WASD/arrow-key camera movement
+ * when no other 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:
+ * focusStack.pushFocusOwner(textEditor);
+ * // Now textEditor receives keyboard events
+ *
+ * // User presses ESC:
+ * focusStack.popFocusOwner();
+ * // Camera movement is restored
+ * }</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 final Stack<KeyboardInputHandler> inputHandlers = new Stack<>();
+ private KeyboardInputHandler currentUserInputHandler;
+
+ /**
+ * Creates a new focus stack for the given view panel, with
+ * {@link WorldNavigationUserInputTracker} as the default focus owner.
+ *
+ * @param viewPanel the view panel this focus stack belongs to
+ */
+ public KeyboardFocusStack(final ViewPanel viewPanel) {
+ this.viewPanel = viewPanel;
+ pushFocusOwner(defaultInputHandler);
+ }
+
+ /**
+ * Returns the handler that currently has keyboard focus.
+ *
+ * @return the current focus owner
+ */
+ public KeyboardInputHandler getCurrentFocusOwner() {
+ return currentUserInputHandler;
+ }
+
+ /**
+ * Removes the current focus owner from the stack and restores focus to the
+ * previous handler. If the stack is empty, no action is taken.
+ */
+ public void popFocusOwner() {
+ if (currentUserInputHandler != null)
+ currentUserInputHandler.focusLost(viewPanel);
+
+ if (inputHandlers.isEmpty())
+ return;
+
+ currentUserInputHandler = inputHandlers.pop();
+ currentUserInputHandler.focusReceived(viewPanel);
+ }
+
+ /**
+ * Pushes a new handler onto the focus stack, making it the current focus owner.
+ * The previous focus owner is notified via {@link KeyboardInputHandler#focusLost}
+ * and preserved on the stack for later restoration.
+ *
+ * <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);
+ inputHandlers.push(currentUserInputHandler);
+ }
+
+ currentUserInputHandler = newInputHandler;
+ updateNeeded |= currentUserInputHandler.focusReceived(viewPanel);
+
+ return updateNeeded;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
+
+import eu.svjatoslav.sixth.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);
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+
+/**
+ * Represents mouse event.
+ */
+public class MouseEvent {
+
+ /**
+ * Mouse coordinate in screen space (pixels) relative to top left corner of the screen
+ * when mouse button was clicked.
+ */
+ public Point2D coordinate;
+
+ /**
+ * <pre>
+ * 0 - mouse over (no button pressed)
+ * 1 - left mouse button
+ * 2 - middle mouse button
+ * 3 - right mouse button
+ * </pre>
+ */
+ 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 +
+ '}';
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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 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();
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
+
+import eu.svjatoslav.sixth.e3d.gui.Camera;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/**
+ * Provides input device tracking (keyboard, mouse) and event forwarding to virtual components.
+ *
+ * @see eu.svjatoslav.sixth.e3d.gui.humaninput.InputManager
+ * @see eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardFocusStack
+ */
+package eu.svjatoslav.sixth.e3d.gui.humaninput;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Graphical user interface components for the Sixth 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.sixth.e3d.gui.ViewPanel} - The main rendering surface (JPanel)</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.gui.ViewFrame} - A JFrame with embedded ViewPanel</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.gui.Camera} - Represents the viewer's position and orientation</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.gui.DeveloperTools} - Debugging and profiling utilities</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.gui.ViewPanel
+ * @see eu.svjatoslav.sixth.e3d.gui.Camera
+ */
+
+package eu.svjatoslav.sixth.e3d.gui;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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 != ' ';
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.textEditorComponent;
+
+import eu.svjatoslav.sixth.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() {
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.gui.textEditorComponent;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.gui.GuiComponent;
+import eu.svjatoslav.sixth.e3d.gui.TextPointer;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardHelper;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.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();
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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();
+ }
+
+}
--- /dev/null
+/**
+ * Provides a simple text editor component rendered in 3D space.
+ *
+ * @see eu.svjatoslav.sixth.e3d.gui.textEditorComponent.TextEditComponent
+ */
+package eu.svjatoslav.sixth.e3d.gui.textEditorComponent;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+package eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.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) {
+ // Lazily create and cache the rotation matrix
+ if (matrixDirty || cachedMatrix == null) {
+ if (cachedMatrix == null) {
+ cachedMatrix = new Matrix3x3();
+ }
+ rotation.copyToMatrix(cachedMatrix);
+ matrixDirty = false;
+ }
+ cachedMatrix.transform(point, point);
+ point.add(translation);
+ }
+
+ /**
+ * 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.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>
+ *
+ * @see Transform
+ */
+public class TransformStack {
+
+ /**
+ * Array of transforms in the stack.
+ * Fixed the size for efficiency to avoid memory allocation during rendering.
+ */
+ private final Transform[] transforms = new Transform[100];
+ /**
+ * The current number of transforms in the stack.
+ */
+ private int transformsCount = 0;
+
+ /**
+ * Creates a new empty transform stack.
+ */
+ public TransformStack() {
+ }
+
+ /**
+ * Pushes a transform onto the stack.
+ *
+ * @param transform the transform to push
+ */
+ public void addTransform(final Transform transform) {
+ transforms[transformsCount] = transform;
+ transformsCount++;
+ }
+
+ /**
+ * Clears all transforms from the stack.
+ */
+ public void clear() {
+ transformsCount = 0;
+ }
+
+ /**
+ * Pops the most recently added transform from the stack.
+ */
+ public void dropTransform() {
+ transformsCount--;
+ }
+
+ /**
+ * Transforms a point through all transforms in the stack.
+ *
+ * @param coordinate the input coordinate (not modified)
+ * @param result the output coordinate (receives transformed result)
+ */
+ public void transform(final Point3D coordinate, final Point3D result) {
+
+ result.clone(coordinate);
+
+ // TODO: Investigate if stack of transforms can be collapsed into single matrix multiplication
+
+ // Apply transforms in reverse order (last added = first applied)
+ for (int i = transformsCount - 1; i >= 0; i--)
+ transforms[i].transform(result);
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.math;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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).
+ * Visible vertices have positive z coordinate (in front of the viewer).
+ * No perspective correction is applied.
+ */
+ public Point3D transformedCoordinate;
+
+ /**
+ * Vertex position on screen in pixels, relative to top-left corner.
+ * Calculated after transformation and perspective projection.
+ */
+ public Point2D onScreenCoordinate;
+
+
+ /**
+ * 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 frame number when this vertex was last transformed (for caching).
+ */
+ private int lastTransformedFrame = -1; // Start at -1 so the first frame (frameNumber=1) will transform
+
+ /**
+ * 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;
+ transformedCoordinate = new Point3D();
+ onScreenCoordinate = new Point2D();
+ this.textureCoordinate = textureCoordinate;
+ }
+
+
+ /**
+ * 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 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) {
+
+ if (lastTransformedFrame == renderContext.frameNumber)
+ return;
+
+ lastTransformedFrame = renderContext.frameNumber;
+
+ 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;
+ }
+
+ // ========== 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;
+ }
+
+ /**
+ * Creates a deep copy of this vertex including screen-space coordinates.
+ *
+ * <p>Unlike {@link #clone()}, this method also copies the
+ * {@link #transformedCoordinate} and {@link #onScreenCoordinate}.
+ * Used by the tessellation system to create sub-triangles with pre-computed screen positions.</p>
+ *
+ * @return a new Vertex with cloned data including screen coordinates
+ */
+ public Vertex cloneWithScreenCoords() {
+ final Vertex result = clone();
+
+ result.transformedCoordinate = new Point3D(transformedCoordinate);
+ result.onScreenCoordinate = new Point2D(onScreenCoordinate);
+
+ 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;
+ }
+}
--- /dev/null
+/**
+ * Sixth 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.sixth.e3d.math;
+
--- /dev/null
+/**
+ * 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.sixth.e3d;
+
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.Ray;
+import eu.svjatoslav.sixth.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.sixth.e3d.renderer.octree.raytracer.RayTracer
+ * @see eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/**
+ * Octree-based voxel volume representation and rendering for the Sixth 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.sixth.e3d.renderer.octree.OctreeVolume} - the main octree data structure
+ * for storing and querying voxel cells</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.octree.IntegerPoint} - integer 3D coordinate used
+ * for voxel addressing</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.octree.raytracer ray tracing through octree volumes
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.octree;
+
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.Camera;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+
+import static eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+
+/**
+ * Represents a ray used for tracing through an {@link eu.svjatoslav.sixth.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.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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.sixth.e3d.renderer.octree.OctreeVolume}'s internal
+ * cell arrays.</p>
+ *
+ * @see Ray
+ * @see RayTracer
+ * @see eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.renderer.octree.OctreeVolume;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.Camera;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.TexturedRectangle;
+import eu.svjatoslav.sixth.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/sixth/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);
+ }
+
+}
--- /dev/null
+/**
+ * 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.sixth.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.sixth.e3d.renderer.octree.raytracer.RayTracer} - main ray tracing engine</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.RaytracingCamera} - camera configuration for ray generation</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.Ray} - represents a single ray cast through the volume</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.LightSource} - defines a light source for shading</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.octree.OctreeVolume the voxel data structure
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer;
+
--- /dev/null
+/**
+ * Sixth 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.sixth.e3d.renderer;
+
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster;
+
+/**
+ * RGBA color representation for the Sixth 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 Sixth 3D engine's rendering pipeline.</p>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ * @see eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster;
+
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Comparator;
+
+/**
+ * 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#onScreenZ
+ */
+public class RenderAggregator {
+
+ /**
+ * Creates a new render aggregator.
+ */
+ public RenderAggregator() {
+ }
+
+ private final ArrayList<AbstractCoordinateShape> shapes = new ArrayList<>();
+ private final ShapesZIndexComparator comparator = new ShapesZIndexComparator();
+ private boolean sorted = false;
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Sorts all queued shapes by Z-depth (back to front).
+ * Must be called after all shapes are queued and before paintSorted.
+ */
+ public void sort() {
+ if (!sorted) {
+ shapes.sort(comparator);
+ sorted = true;
+ }
+ }
+
+ 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.
+ *
+ * @param renderBuffer the rendering context to paint shapes into
+ */
+ public void paintSorted(final RenderingContext renderBuffer) {
+ for (int i = 0; i < shapes.size(); i++)
+ shapes.get(i).paint(renderBuffer);
+ }
+
+ /**
+ * Returns the number of shapes currently queued.
+ *
+ * @return the shape count
+ */
+ public int size() {
+ 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);
+ }
+
+ /**
+ * Clears all queued shapes, preparing for a new render frame.
+ */
+ public void reset() {
+ shapes.clear();
+ sorted = 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 {
+
+ @Override
+ public int compare(final AbstractCoordinateShape o1, final AbstractCoordinateShape o2) {
+ if (o1.getZ() < o2.getZ())
+ return 1;
+ else if (o1.getZ() > o2.getZ())
+ return -1;
+
+ return Integer.compare(o1.shapeId, o2.shapeId);
+ }
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster;
+
+import eu.svjatoslav.sixth.e3d.geometry.Frustum;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.CullingStatistics;
+import eu.svjatoslav.sixth.e3d.gui.Camera;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.gui.ViewPanel;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.SubShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.AdaptiveTessellationController;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 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 {
+
+ /**
+ * The render aggregator that collects transformed shapes, sorts by depth, and paints.
+ */
+ private final RenderAggregator aggregator = new RenderAggregator();
+
+ /**
+ * The transform stack used during the rendering pipeline.
+ */
+ private final TransformStack transformStack = new TransformStack();
+
+
+ // 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);
+ }
+
+ /**
+ * 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) {
+
+ aggregator.reset();
+ transformStack.clear();
+
+ // Reset adaptive tessellation counter for this frame
+ AdaptiveTessellationController.getInstance().resetFrameCount();
+
+ final Camera camera = viewPanel.getCamera();
+
+ // 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();
+ cameraTranslationTransform.getTranslation().x = -cameraLocation.x;
+ cameraTranslationTransform.getTranslation().y = -cameraLocation.y;
+ cameraTranslationTransform.getTranslation().z = -cameraLocation.z;
+ transformStack.addTransform(cameraTranslationTransform);
+
+ rootComposite.transform(transformStack, aggregator, renderingContext);
+ }
+
+ /**
+ * Sorts all queued shapes by Z-depth (back to front).
+ * This is phase 2 of the multi-threaded render pipeline.
+ */
+ public void sortShapes() {
+ aggregator.sort();
+ }
+
+ /**
+ * 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) {
+ aggregator.paintSorted(renderingContext);
+
+ // Update adaptive tessellation threshold based on this frame's polygon count
+ // If threshold changed significantly, invalidate caches to retessellate with new threshold
+ if (AdaptiveTessellationController.getInstance().endFrame()) {
+ rootComposite.setCacheNeedsRebuild(true);
+ }
+ }
+
+ /**
+ * Returns the number of shapes queued for rendering.
+ *
+ * @return the shape count
+ */
+ public int getQueuedShapeCount() {
+ return aggregator.size();
+ }
+
+ /**
+ * Sets the cache rebuild flag on the root composite.
+ *
+ * <p>Used internally to force retessellate when needed. 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.lighting;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.lighting;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+
+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.sixth.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);
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.lighting.LightingManager} - Manages lights and calculates shading</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightSource} - Represents a point light source</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightingManager
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.lighting;
\ No newline at end of file
--- /dev/null
+/**
+ * Rasterization-based real-time software renderer for the Sixth 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.sixth.e3d.renderer.raster.ShapeCollection} - root container for all 3D shapes in a scene</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator} - collects and depth-sorts shapes for rendering</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.Color} - RGBA color representation with predefined constants</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic basic shape primitives (lines, polygons)
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite composite shapes (boxes, grids, text)
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.texture texture and mipmap support
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster;
+
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes;
+
+import eu.svjatoslav.sixth.e3d.geometry.Box;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.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.
+ * Used by the {@link RenderAggregator} to sort shapes back-to-front
+ * for correct painter's algorithm rendering.
+ */
+ public double onScreenZ;
+
+ /**
+ * 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.
+ *
+ * @return the average Z-depth value, used for depth sorting
+ */
+ public double getZ() {
+ return onScreenZ;
+ }
+
+ /**
+ * 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.MIN_VALUE;
+ double minY = Double.MAX_VALUE;
+ double maxY = Double.MIN_VALUE;
+ double minZ = Double.MAX_VALUE;
+ double maxZ = Double.MIN_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 Sixth 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);
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>Transforms all vertices to screen space by applying the current transform stack.
+ * Computes the average Z-depth and, if all vertices are visible (in front of the camera),
+ * queues this shape for rendering.</p>
+ */
+ @Override
+ public void transform(final TransformStack transforms,
+ final RenderAggregator aggregator,
+ final RenderingContext renderingContext) {
+
+ double accumulatedZ = 0;
+ boolean paint = true;
+
+ for (final Vertex geometryPoint : vertices) {
+ geometryPoint.calculateLocationRelativeToViewer(transforms, renderingContext);
+
+ accumulatedZ += geometryPoint.transformedCoordinate.z;
+
+ if (!geometryPoint.transformedCoordinate.isVisible()) {
+ paint = false;
+ }
+ }
+
+ if (paint) {
+ onScreenZ = accumulatedZ / vertices.size();
+ aggregator.queueShapeForRendering(this);
+ }
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes;
+
+import eu.svjatoslav.sixth.e3d.geometry.Box;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator;
+
+/**
+ * Base class for all renderable shapes in the Sixth 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.sixth.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);
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.sixth.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);
+ }
+
+ /**
+ * 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) {
+
+ // distance from camera/viewer to center of the texture
+ final double z = vertices.get(0).transformedCoordinate.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;
+
+ // 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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+
+/**
+ * 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;
+ }
+
+ if (x2 >= renderBuffer.renderMaxX)
+ x2 = renderBuffer.renderMaxX - 1;
+
+ 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 = vertices.get(0).onScreenCoordinate;
+ final Point2D onScreenPoint2 = vertices.get(1).onScreenCoordinate;
+
+ 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 >= 0) && (x < buffer.width)) {
+
+ 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 = vertices.get(0).onScreenCoordinate;
+ final Point2D onScreenPoint2 = vertices.get(1).onScreenCoordinate;
+
+ 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 >= 0) && (x < buffer.width)) {
+ 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;
+ }
+
+ /**
+ * 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) {
+
+ final Point2D onScreenPoint1 = vertices.get(0).onScreenCoordinate;
+ final Point2D onScreenPoint2 = vertices.get(1).onScreenCoordinate;
+
+ final double xp = onScreenPoint2.x - onScreenPoint1.x;
+ final double yp = onScreenPoint2.y - onScreenPoint1.y;
+
+ final double point1radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
+ / vertices.get(0).transformedCoordinate.z;
+ final double point2radius = (buffer.width * LINE_WIDTH_MULTIPLIER * width)
+ / vertices.get(1).transformedCoordinate.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);
+ else
+ drawSinglePixelVerticalLine(buffer, alpha);
+ 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);
+ }
+ }
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.basic.line.Line} - The line shape</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance} - Color and width configuration</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineInterpolator} - Scanline edge interpolation</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.basic.Billboard} - Textures that always face the camera</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.GlowingPoint} - Circular gradient billboards</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.Billboard
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon;
+
+import eu.svjatoslav.sixth.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.sixth.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;
+
+ /**
+ * 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;
+ }
+
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon;
+
+import eu.svjatoslav.sixth.e3d.geometry.Plane;
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static eu.svjatoslav.sixth.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 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<Point3D>.</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.
+ *
+ * @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);
+
+ if (x1 > x2) {
+ final int tmp = x1;
+ x1 = x2;
+ x2 = tmp;
+ }
+
+ if (x1 < renderBuffer.renderMinX) x1 = renderBuffer.renderMinX;
+
+ if (x2 >= renderBuffer.renderMaxX) x2 = renderBuffer.renderMaxX - 1;
+
+ final int width = x2 - x1;
+
+ int offset = (y * renderBuffer.width) + x1;
+ final int[] pixels = renderBuffer.pixels;
+
+ final int polygonAlpha = color.a;
+ final int r = color.r;
+ final int g = color.g;
+ final int b = color.b;
+
+ if (polygonAlpha == 255) {
+ if (width > 0) {
+ final int pixel = (r << 16) | (g << 8) | b;
+ java.util.Arrays.fill(pixels, offset, offset + width, pixel);
+ }
+ } 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++) {
+ 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;
+ }
+ }
+ }
+
+ /**
+ * 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 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 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 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);
+
+ 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) {
+ if (vertices.size() < 3 || color.isTransparent()) {
+ return;
+ }
+
+ // Get thread-local screen points array
+ final Point2D[] screenPoints = getScreenPoints(vertices.size());
+
+ // Get screen coordinates
+ for (int i = 0; i < vertices.size(); i++) {
+ screenPoints[i] = vertices.get(i).onScreenCoordinate;
+ }
+
+ // Backface culling check
+ if (backfaceCulling) {
+ final double signedArea = calculateSignedArea(screenPoints, vertices.size());
+ if (signedArea >= 0) {
+ return;
+ }
+ }
+
+ // Use pre-computed shaded color (computed during transform phase)
+ final Color paintColor = shadingEnabled ? shadedColor : color;
+
+ // Mouse interaction
+ if (mouseInteractionController != null && renderBuffer.getMouseEvent() != null) {
+ if (isPointInsidePolygon(renderBuffer.getMouseEvent().coordinate, screenPoints, vertices.size())) {
+ renderBuffer.setCurrentObjectUnderMouseCursor(mouseInteractionController);
+ }
+ }
+
+ // Only triangles can be rendered directly; N-vertex polygons must be triangulated
+ // by AbstractCompositeShape.retessellate() before rendering
+ if (vertices.size() != 3) {
+ throw new IllegalStateException(
+ "SolidPolygon with " + vertices.size() + " vertices cannot be rendered directly. "
+ + "Only triangles (3 vertices) support direct rendering. "
+ + "For N-vertex polygons, use AbstractCompositeShape which triangulates during retessellate().");
+ }
+
+ drawTriangle(renderBuffer, screenPoints[0], screenPoints[1], screenPoints[2],
+ mouseInteractionController, paintColor);
+ }
+
+ /**
+ * 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(
+ cachedCenter, cachedNormal, color, shadedColor);
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon} - Unified polygon for rendering and CSG</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator} - Edge interpolation for scanlines</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.sixth.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.sixth.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;
+ }
+
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
+
+/**
+ * A textured triangle with pre-computed screen-space coordinates.
+ *
+ * <p>This class is created by the {@link eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.ScreenSpaceTessellator}
+ * during tessellation. Unlike regular {@link TexturedTriangle}, it skips the vertex transformation
+ * step during rendering because its screen coordinates and Z-depth are already computed.</p>
+ *
+ * <p>This optimization avoids redundant coordinate transformation for tessellated sub-triangles,
+ * improving performance when large polygons are split into many smaller pieces.</p>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.ScreenSpaceTessellator
+ * @see TexturedTriangle
+ */
+public class TessellatedTexturedTriangle extends TexturedTriangle {
+
+ /**
+ * Creates a tessellated textured triangle with pre-computed screen coordinates.
+ *
+ * <p>The vertices' {@link Vertex#onScreenCoordinate} and {@link Vertex#transformedCoordinate}
+ * are already populated, so the {@link #transform} method will skip coordinate calculation.</p>
+ *
+ * @param v1 the first vertex (world coords, texture coords, screen coords pre-computed)
+ * @param v2 the second vertex (world coords, texture coords, screen coords pre-computed)
+ * @param v3 the third vertex (world coords, texture coords, screen coords pre-computed)
+ * @param texture the texture to apply
+ */
+ public TessellatedTexturedTriangle(final Vertex v1, final Vertex v2, final Vertex v3,
+ final Texture texture) {
+ super(v1, v2, v3, texture);
+ }
+
+ /**
+ * Transforms this triangle for rendering by transforming vertices normally.
+ *
+ * <p>Unlike the original design that skipped transformation, this method now
+ * transforms vertices normally using their world coordinates. The world coordinates
+ * were correctly interpolated during tessellation, so the resulting screen coordinates
+ * will be fresh for each frame.</p>
+ *
+ * @param transforms the transform stack to apply
+ * @param aggregator the aggregator to queue this shape for rendering
+ * @param renderingContext the rendering context
+ */
+ @Override
+ public void transform(final eu.svjatoslav.sixth.e3d.math.TransformStack transforms,
+ final RenderAggregator aggregator,
+ final RenderingContext renderingContext) {
+
+ // Transform vertices normally using world coordinates
+ // (world coords were interpolated correctly during tessellation)
+ double accumulatedZ = 0;
+ boolean paint = true;
+
+ for (final Vertex v : vertices) {
+ v.calculateLocationRelativeToViewer(transforms, renderingContext);
+ accumulatedZ += v.transformedCoordinate.z;
+
+ if (!v.transformedCoordinate.isVisible()) {
+ paint = false;
+ }
+ }
+
+ if (paint) {
+ onScreenZ = accumulatedZ / vertices.size();
+ aggregator.queueShapeForRendering(this);
+ }
+ }
+
+ /**
+ * Creates a tessellated triangle by interpolating between two vertices.
+ *
+ * <p>This factory method creates a new vertex at the midpoint of two vertices,
+ * interpolating world coordinates, texture coordinates, screen coordinates,
+ * and Z-depth. Used during tessellation to split edges.</p>
+ *
+ * @param v1 the first vertex endpoint
+ * @param v2 the second vertex endpoint
+ * @param screen1 the screen coordinate of the first vertex
+ * @param screen2 the screen coordinate of the second vertex
+ * @return a new vertex at the midpoint with interpolated properties
+ */
+ public static Vertex interpolateMidpoint(final Vertex v1, final Vertex v2,
+ final Point2D screen1, final Point2D screen2) {
+ final Point3D worldMid = new Point3D().computeMiddlePoint(v1.coordinate, v2.coordinate);
+ final Point2D texMid = new Point2D().setToMiddle(v1.textureCoordinate, v2.textureCoordinate);
+
+ final Vertex result = new Vertex(worldMid, texMid);
+
+ // Interpolate screen coordinates
+ result.onScreenCoordinate = new Point2D(
+ (screen1.x + screen2.x) / 2,
+ (screen1.y + screen2.y) / 2
+ );
+
+ // Interpolate transformed Z
+ result.transformedCoordinate = new Point3D(
+ (v1.transformedCoordinate.x + v2.transformedCoordinate.x) / 2,
+ (v1.transformedCoordinate.y + v2.transformedCoordinate.y) / 2,
+ (v1.transformedCoordinate.z + v2.transformedCoordinate.z) / 2
+ );
+
+ return result;
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.TextureBitmap;
+
+import java.awt.*;
+
+import static eu.svjatoslav.sixth.e3d.geometry.Polygon.pointWithinPolygon;
+
+/**
+ * A textured triangle renderer with perspective-correct texture mapping.
+ *
+ * <p>This class renders triangles with UV-mapped textures. For large triangles,
+ * the rendering may be tessellated into smaller pieces for better perspective correction.</p>
+ *
+ * <p><b>Perspective-correct texture rendering:</b></p>
+ * <ul>
+ * <li>Small triangles are rendered without perspective correction</li>
+ * <li>Larger triangles are tessellated into smaller pieces for accurate perspective</li>
+ * </ul>
+ *
+ * @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()
+ });
+
+ /**
+ * The texture to apply to this triangle.
+ */
+ public final Texture texture;
+
+ private boolean backfaceCulling = false;
+
+ /**
+ * 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();
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Draws a horizontal scanline between two edge interpolators with texture sampling.
+ *
+ * @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 textureBitmap the texture bitmap to sample from
+ */
+ private void drawHorizontalLine(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 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 < renderBuffer.renderMinX)
+ x1 = renderBuffer.renderMinX;
+
+ if (x2 >= renderBuffer.renderMaxX)
+ x2 = renderBuffer.renderMaxX - 1;
+
+ int renderBufferOffset = (y * renderBuffer.width) + x1;
+ final int[] renderBufferPixels = renderBuffer.pixels;
+
+ 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 backgroundAlpha = 255 - srcAlpha;
+
+ final int srcR = ((srcPixel >> 16) & 0xff) * srcAlpha;
+ final int srcG = ((srcPixel >> 8) & 0xff) * srcAlpha;
+ final int srcB = (srcPixel & 0xff) * srcAlpha;
+
+ 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 * backgroundAlpha) + srcR) >> 8;
+ final int g = ((destG * backgroundAlpha) + srcG) >> 8;
+ final int b = ((destB * backgroundAlpha) + srcB) >> 8;
+
+ renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+ }
+ }
+
+ tx += txStep;
+ ty += tyStep;
+ renderBufferOffset++;
+ }
+
+ }
+
+ /**
+ * Renders this textured triangle to the screen.
+ *
+ * <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
+ */
+ @Override
+ public void paint(final RenderingContext renderBuffer) {
+
+ final Point2D projectedPoint1 = vertices.get(0).onScreenCoordinate;
+ final Point2D projectedPoint2 = vertices.get(1).onScreenCoordinate;
+ final Point2D projectedPoint3 = vertices.get(2).onScreenCoordinate;
+
+ 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;
+ }
+
+ if (mouseInteractionController != null)
+ if (renderBuffer.getMouseEvent() != null)
+ if (pointWithinPolygon(
+ renderBuffer.getMouseEvent().coordinate, projectedPoint1, projectedPoint2, projectedPoint3))
+ renderBuffer.setCurrentObjectUnderMouseCursor(mouseInteractionController);
+
+ // 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) return;
+
+ // paint
+ double totalVisibleDistance = projectedPoint1.getDistanceTo(projectedPoint2);
+ totalVisibleDistance += projectedPoint1.getDistanceTo(projectedPoint3);
+ totalVisibleDistance += projectedPoint2.getDistanceTo(projectedPoint3);
+
+ final double scaleFactor = (totalVisibleDistance / totalTextureDistance) * 1.2d;
+
+ final TextureBitmap mipmap = texture.getMipmapForScale(scaleFactor);
+
+ final PolygonBorderInterpolator[] interpolators = INTERPOLATORS.get();
+ final PolygonBorderInterpolator pbi1 = interpolators[0];
+ final PolygonBorderInterpolator pbi2 = interpolators[1];
+ final PolygonBorderInterpolator pbi3 = interpolators[2];
+
+ pbi1.setPoints(projectedPoint1, projectedPoint2, vertices.get(0).textureCoordinate, vertices.get(1).textureCoordinate);
+ pbi2.setPoints(projectedPoint1, projectedPoint3, vertices.get(0).textureCoordinate, vertices.get(2).textureCoordinate);
+ pbi3.setPoints(projectedPoint2, projectedPoint3, vertices.get(1).textureCoordinate, vertices.get(2).textureCoordinate);
+
+ for (int y = yTop; y <= yBottom; y++) {
+ if (pbi1.containsY(y)) {
+ if (pbi2.containsY(y))
+ drawHorizontalLine(pbi1, pbi2, y, renderBuffer, mipmap);
+ else if (pbi3.containsY(y))
+ drawHorizontalLine(pbi1, pbi3, y, renderBuffer, mipmap);
+ } else if (pbi2.containsY(y)) {
+ if (pbi3.containsY(y))
+ drawHorizontalLine(pbi2, pbi3, y, renderBuffer, mipmap);
+ }
+ }
+
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * The screen-space edge length used for the last tessellation decision.
+ * Used to cache tessellation results - only retessellate when size changes significantly.
+ * A value of 0 indicates this triangle has never been tessellated based on screen-space metrics.
+ */
+ private double lastScreenSpaceSize = 0;
+
+ /**
+ * Returns the screen-space edge length from the last tessellation decision.
+ * Used by AbstractCompositeShape to determine if retessellation is needed.
+ *
+ * @return the last recorded screen-space size, or 0 if never tessellated
+ */
+ public double getLastScreenSpaceSize() {
+ return lastScreenSpaceSize;
+ }
+
+ /**
+ * Updates the recorded screen-space size after tessellation.
+ *
+ * @param size the screen-space edge length that was used for tessellation
+ */
+ public void setLastScreenSpaceSize(final double size) {
+ this.lastScreenSpaceSize = size;
+ }
+
+ /**
+ * Checks if this triangle has valid screen coordinates from a previous frame.
+ * Used to determine if screen-space tessellation can be applied.
+ *
+ * @return {@code true} if at least one vertex has non-origin screen coordinates
+ */
+ public boolean hasPreviousScreenCoordinates() {
+ // Check if any vertex has been transformed (screen coords not at origin)
+ final Point2D screen1 = vertices.get(0).onScreenCoordinate;
+ return screen1.x != 0 || screen1.y != 0;
+ }
+
+ /**
+ * Computes the maximum screen-space edge length from current vertex screen coordinates.
+ * Used to determine tessellation level based on visual size.
+ *
+ * @return the longest edge length in screen pixels
+ */
+ public double computeMaxScreenEdgeLength() {
+ final Point2D s1 = vertices.get(0).onScreenCoordinate;
+ final Point2D s2 = vertices.get(1).onScreenCoordinate;
+ final Point2D s3 = vertices.get(2).onScreenCoordinate;
+
+ final double d1 = s1.getDistanceTo(s2);
+ final double d2 = s2.getDistanceTo(s3);
+ final double d3 = s3.getDistanceTo(s1);
+
+ return Math.max(d1, Math.max(d2, d3));
+ }
+
+ /**
+ * Determines if retessellation is needed based on screen-space size change.
+ * Uses the same 1.5x ratio threshold as AbstractCompositeShape uses for LOD factors.
+ *
+ * @param currentScreenSize the current screen-space edge length
+ * @return {@code true} if retessellation should be performed
+ */
+ public boolean needsRetessellation(final double currentScreenSize) {
+ if (lastScreenSpaceSize == 0) {
+ // Never tessellated - need to tessellate now
+ return true;
+ }
+
+ final double larger = Math.max(currentScreenSize, lastScreenSpaceSize);
+ final double smaller = Math.min(currentScreenSize, lastScreenSpaceSize);
+
+ // Retessellate if size changed by more than 1.5x ratio
+ return (larger / smaller) > 1.5;
+ }
+
+ /**
+ * 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;
+ final Point2D projectedPoint2 = vertices.get(1).onScreenCoordinate;
+ final Point2D projectedPoint3 = vertices.get(2).onScreenCoordinate;
+
+ 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);
+ });
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 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.
+ * Screen-space tessellation is applied automatically during the transform phase
+ * for better perspective correction on large triangles.</p>
+ *
+ * <p>Key classes:</p>
+ * <ul>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle} -
+ * The base textured triangle with screen-space tessellation</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TessellatedTexturedTriangle} -
+ * Pre-transformed sub-triangle created during tessellation</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.PolygonBorderInterpolator} -
+ * Edge interpolation with UVs</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TessellatedTexturedTriangle
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.Billboard;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
+
+/**
+ * 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 font metrics
+ * defined in {@link TextCanvas} ({@link TextCanvas#FONT}, {@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 {
+
+ /**
+ * 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.sixth.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 font metrics
+ * defined in {@link TextCanvas}. Each character is drawn individually at the appropriate
+ * horizontal offset using {@link TextCanvas#FONT}.</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.sixth.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(TextCanvas.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.sixth.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;
+ }
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.GlowingPoint;
+import eu.svjatoslav.sixth.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));
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape;
+import eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base;
+
+import eu.svjatoslav.sixth.e3d.geometry.Box;
+import eu.svjatoslav.sixth.e3d.geometry.BspTree;
+import eu.svjatoslav.sixth.e3d.geometry.Frustum;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.gui.ViewSpaceTracker;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TessellatedTexturedTriangle;
+import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.AdaptiveTessellationController;
+import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator;
+import eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.ScreenSpaceTessellator;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * 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>Level-of-detail tessellation:</b></p>
+ * <p>Textured polygons within the composite shape are automatically tessellated into smaller
+ * triangles based on distance from the viewer. This provides perspective-correct texture
+ * mapping without requiring hardware support. The tessellation factor adapts dynamically.</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.sixth.e3d.renderer.raster.shapes.AbstractShape the base shape class
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator the level-of-detail polygon tessellator
+ */
+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 to compute
+ * an appropriate tessellation factor for level-of-detail adjustments.
+ */
+ private final ViewSpaceTracker viewSpaceTracker;
+
+ /**
+ * The current tessellation factor used for tessellating textured polygons into smaller
+ * triangles for perspective-correct rendering. Higher values produce more triangles
+ * for distant objects; lower values for nearby objects. Updated dynamically based
+ * on view-space analysis.
+ * <p>
+ * TODO: move this to TexturedTriangle. LOD must be computed per textured triangle, not per-shape.
+ */
+ double currentTessellationFactor = 5;
+
+ /**
+ * Thread-local screen-space tessellator for per-polygon tessellation based on visual size.
+ * Reused across frames to avoid object allocation overhead.
+ * The threshold is updated via setMaxScreenPixels() before each tessellation.
+ */
+ private static final ThreadLocal<ScreenSpaceTessellator> SCREEN_SPACE_TESSELLATOR =
+ ThreadLocal.withInitial(() -> new ScreenSpaceTessellator(
+ AdaptiveTessellationController.MIN_THRESHOLD));
+
+ /**
+ * 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>Non-textured shapes (Line, SolidPolygon) - passed through directly</li>
+ * <li>Textured polygons - tessellated into smaller triangles based on current LOD factor</li>
+ * </ul>
+ *
+ * <p><b>Caching strategy:</b> Regenerating this list involves texture tessellation which
+ * is expensive. 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<>();
+
+ /**
+ * Number of original TexturedTriangle that were tessellated to produce TessellatedTexturedTriangle in cachedRenderList.
+ * Used to compute "polygons created by tessellation" = TessellatedTexturedTriangle count - this count.
+ */
+ private int originalTessellationInputCount = 0;
+
+ /**
+ * 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>
+ * <li>The tessellation factor changes significantly (determined by {@link #isRetessellationNeeded})</li>
+ * </ul>
+ *
+ * <p>Set to {@code false} after {@link #retessellate} 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>Root composites have different behavior for LOD-based tessellation:</p>
+ * <ul>
+ * <li>Root position equals camera position, so distance to camera is always 0</li>
+ * <li>ViewSpaceTracker cannot compute meaningful tessellation factor for root</li>
+ * <li>Root uses fixed {@link #currentTessellationFactor} and skips LOD-based retessellation checks</li>
+ * <li>Root still performs N-gon triangulation when {@link #cacheNeedsRebuild} is true</li>
+ * </ul>
+ *
+ * <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.MIN_VALUE;
+ double minY = Double.MAX_VALUE;
+ double maxY = Double.MIN_VALUE;
+ double minZ = Double.MAX_VALUE;
+ double maxZ = Double.MIN_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 sub-shape is a composite, apply its transform to the bounds
+ 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;
+ }
+ }
+ }
+
+ /**
+ * Determines whether textured polygons need to be re-tessellated based on tessellation factor change.
+ * <p>
+ * Re-tessellation is needed if the tessellation state is marked outdated, or if the ratio between
+ * the larger and smaller tessellation factor exceeds 1.5x. This threshold prevents frequent
+ * re-tessellation for minor view changes while ensuring significant LOD changes trigger updates.
+ *
+ * @param proposedNewTessellationFactor the tessellation factor computed from current view distance
+ * @param currentTessellationFactor the tessellation factor currently in use
+ * @return {@code true} if re-tessellation should be performed
+ */
+ private boolean isRetessellationNeeded(final double proposedNewTessellationFactor, final double currentTessellationFactor) {
+
+ if (cacheNeedsRebuild)
+ return true;
+
+ // retessellate if there is significant difference between proposed and current tessellation factor
+ final double larger = Math.max(proposedNewTessellationFactor, currentTessellationFactor);
+ final double smaller = Math.min(proposedNewTessellationFactor, currentTessellationFactor);
+
+ return (larger / smaller) > 1.5d;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Checks if re-slicing is needed and performs it if so.
+ *
+ * <p>For root composites, skips LOD-based checks since distance to camera is 0.
+ * Only retessellates when {@link #cacheNeedsRebuild} is true (shapes added/removed/visibility changed).</p>
+ *
+ * <p>For normal composites, checks both cache validity and LOD-based tessellation factor changes.</p>
+ *
+ * @param context the rendering context for logging
+ */
+ private void retessellateIfNeeded(final TransformStack transformPipe, final RenderingContext context) {
+
+ if (isRootComposite) {
+ if (cacheNeedsRebuild)
+ retessellate(transformPipe, context);
+ else {
+ // For root composites with TexturedTriangles, check if screen-space sizes changed
+ // This enables per-polygon LOD to adapt when camera moves
+ boolean needsRetessellation = false;
+ for (int i = 0; i < subShapesRegistry.size() && !needsRetessellation; i++) {
+ final SubShape subShape = subShapesRegistry.get(i);
+ if (!subShape.isVisible())
+ continue;
+ final AbstractShape shape = subShape.getShape();
+ if (shape instanceof TexturedTriangle triangle) {
+ // Compute screen coords for this triangle to check size
+ computeScreenCoordsForTriangle(triangle, transformPipe, context);
+ if (triangle.needsRetessellation(triangle.computeMaxScreenEdgeLength())) {
+ needsRetessellation = true;
+ }
+ }
+ }
+ if (needsRetessellation)
+ retessellate(transformPipe, context);
+ }
+ return;
+ }
+
+ final double proposedTessellationFactor = viewSpaceTracker.proposeTessellationFactor();
+
+ if (isRetessellationNeeded(proposedTessellationFactor, currentTessellationFactor)) {
+ currentTessellationFactor = proposedTessellationFactor;
+ retessellate(transformPipe, 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>Root composites skip LOD-based tessellation factor checks since their position
+ * equals the camera position (distance = 0). They use a fixed tessellation factor
+ * and only retessellate when shapes are added, removed, or visibility changes.</p>
+ *
+ * <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 retessellate 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;
+ }
+ }
+ }
+
+ /**
+ * Retessellates all textured polygons, triangulates N-vertex solid polygons,
+ * and rebuilds the cached render list.
+ * Logs the operation to the debug log buffer if available.
+ *
+ * @param context the rendering context for logging, may be {@code null}
+ */
+ private void retessellate(final TransformStack transformPipe, final RenderingContext context) {
+ cacheNeedsRebuild = false;
+
+ final List<AbstractShape> result = new ArrayList<>();
+ originalTessellationInputCount = 0; // Reset before counting
+
+ final TexturedPolygonTessellator tessellator = new TexturedPolygonTessellator(currentTessellationFactor);
+ 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 triangle) {
+ // Compute screen coords before tessellation so ScreenSpaceTessellator
+ // can make accurate decisions based on actual visual size
+ computeScreenCoordsForTriangle(triangle, transformPipe, context);
+
+ final double tessellationThreshold = AdaptiveTessellationController.getInstance().getThreshold();
+
+ final ScreenSpaceTessellator ssTessellator = SCREEN_SPACE_TESSELLATOR.get();
+ ssTessellator.setMaxScreenPixels(tessellationThreshold);
+ ssTessellator.tessellate(triangle);
+ final List<TessellatedTexturedTriangle> ssResult = ssTessellator.getResult();
+ result.addAll(ssResult);
+ 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++;
+ }
+ }
+
+ result.addAll(tessellator.getResult());
+
+ originalTessellationInputCount = texturedPolygonCount;
+ cachedRenderList = result;
+
+ if (context != null && context.debugLogBuffer != null) {
+ context.debugLogBuffer.log("retessellate: " + getClass().getSimpleName()
+ + " tessellationFactor=" + String.format("%.2f", currentTessellationFactor)
+ + " texturedPolygons=" + texturedPolygonCount
+ + " solidPolygons=" + solidPolygonCount
+ + " triangulatedPolygons=" + triangulatedPolygonCount
+ + " otherShapes=" + otherShapeCount
+ + " resultingTexturedPolygons=" + tessellator.getResult().size());
+ }
+ }
+
+ /**
+ * 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++;
+ }
+
+ 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++;
+ }
+ transformPipe.dropTransform();
+ return;
+ }
+ }
+
+ viewSpaceTracker.analyze(transformPipe, context);
+
+ beforeTransformHook(transformPipe, context);
+
+ retessellateIfNeeded(transformPipe, context);
+
+ // transform rendered subshapes
+ // Count tessellated polygons in cached render list and subtract originals to get "created by tessellation"
+ int tessellatedPolygonCount = 0;
+ for (final AbstractShape shape : cachedRenderList) {
+ shape.transform(transformPipe, aggregator, context);
+ if (shape instanceof TessellatedTexturedTriangle) {
+ tessellatedPolygonCount++;
+ }
+ }
+ // Report polygons CREATED by tessellation (output - input originals)
+ final int createdByTessellation = tessellatedPolygonCount - originalTessellationInputCount;
+ if (createdByTessellation > 0) {
+ AdaptiveTessellationController.getInstance().addTessellatedPolygons(createdByTessellation);
+ }
+
+ transformPipe.dropTransform();
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Computes screen coordinates for a TexturedTriangle's vertices.
+ * Called before ScreenSpaceTessellator to ensure accurate screen-space size decisions.
+ *
+ * <p>This method transforms each vertex's world coordinates through the transform stack
+ * and projects to screen coordinates, populating the vertex's transformedCoordinate
+ * and onScreenCoordinate fields.</p>
+ *
+ * @param triangle the triangle to compute screen coords for
+ * @param transformPipe the current transform stack
+ * @param context the rendering context (for screen projection parameters)
+ */
+ private void computeScreenCoordsForTriangle(final TexturedTriangle triangle,
+ final TransformStack transformPipe,
+ final RenderingContext context) {
+ for (final Vertex v : triangle.vertices) {
+ // Transform world coords to view space
+ transformPipe.transform(v.coordinate, v.transformedCoordinate);
+ // Project to screen coords
+ v.onScreenCoordinate.x = (v.transformedCoordinate.x / v.transformedCoordinate.z) * context.projectionScale;
+ v.onScreenCoordinate.y = (v.transformedCoordinate.y / v.transformedCoordinate.z) * context.projectionScale;
+ v.onScreenCoordinate.add(context.centerCoordinate);
+ }
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base;
+
+import java.util.Objects;
+
+import eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.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.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.SubShape
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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 Sixth 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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 Sixth 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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import eu.svjatoslav.sixth.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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube} - A solid cube</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox} - A solid box</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere} - A solid sphere</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder} - A solid cylinder</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid} - A solid pyramid</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape;
+
+import java.awt.*;
+
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import static eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon.drawTriangle;
+import static eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_HEIGHT;
+import static eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas.TextCanvas.FONT_CHAR_WIDTH;
+import static java.lang.String.valueOf;
+
+/**
+ * Represents a single character on the text canvas.
+ */
+public class CanvasCharacter extends AbstractCoordinateShape {
+
+ private static final int MAX_FONT_SIZE = 500;
+
+ /**
+ * Cached fonts.
+ */
+ private static final Font[] fonts = new Font[MAX_FONT_SIZE];
+
+ /**
+ * The character to be rendered.
+ */
+ private char value;
+
+ /**
+ * The foreground color of the character.
+ */
+ private eu.svjatoslav.sixth.e3d.renderer.raster.Color foregroundColor;
+
+ /**
+ * The background color of the character.
+ */
+ private eu.svjatoslav.sixth.e3d.renderer.raster.Color backgroundColor;
+
+ /**
+ * Creates a canvas character at the specified location with given colors.
+ *
+ * @param centerLocation the center position in 3D space
+ * @param character the character to render
+ * @param foregroundColor the foreground (text) color
+ * @param backgroundColor the background color
+ */
+ public CanvasCharacter(final Point3D centerLocation, final char character,
+ final eu.svjatoslav.sixth.e3d.renderer.raster.Color foregroundColor,
+ final eu.svjatoslav.sixth.e3d.renderer.raster.Color backgroundColor) {
+
+ // There are 5 coordinates: center, upper left, upper right, lower right, lower left
+ super(5);
+
+ value = character;
+ this.foregroundColor = foregroundColor;
+ this.backgroundColor = backgroundColor;
+
+
+ vertices.get(0).coordinate = centerLocation;
+
+ final double halfWidth = FONT_CHAR_WIDTH / 2d;
+ final double halfHeight = FONT_CHAR_HEIGHT / 2d;
+
+ // upper left
+ vertices.get(1).coordinate = centerLocation.clone().translateX(-halfWidth)
+ .translateY(-halfHeight);
+
+ // upper right
+ vertices.get(2).coordinate = centerLocation.clone().translateX(halfWidth)
+ .translateY(-halfHeight);
+
+ // lower right
+ vertices.get(3).coordinate = centerLocation.clone().translateX(halfWidth)
+ .translateY(halfHeight);
+
+ // lower left
+ vertices.get(4).coordinate = centerLocation.clone().translateX(-halfWidth)
+ .translateY(halfHeight);
+ }
+
+ /**
+ * Returns a font of the specified size.
+ * <p>
+ * If the font of the specified size is already cached, it will be
+ * returned. Otherwise, a new font will be created, cached and returned.
+ *
+ * @param size the size of the font
+ * @return the font
+ */
+ public static Font getFont(final int size) {
+ if (fonts[size] != null)
+ return fonts[size];
+
+ final Font font = new Font("Courier", Font.BOLD, size);
+ fonts[size] = font;
+ return font;
+ }
+
+ /**
+ * Returns the background color of the character.
+ *
+ * @return the background color
+ */
+ public eu.svjatoslav.sixth.e3d.renderer.raster.Color getBackgroundColor() {
+ return backgroundColor;
+ }
+
+ /**
+ * Sets the background color of the character.
+ *
+ * @param backgroundColor the new background color
+ */
+ public void setBackgroundColor(
+ final eu.svjatoslav.sixth.e3d.renderer.raster.Color backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ }
+
+ /**
+ * Returns color of the foreground.
+ *
+ * @return the color
+ */
+ public eu.svjatoslav.sixth.e3d.renderer.raster.Color getForegroundColor() {
+ return foregroundColor;
+ }
+
+ /**
+ * Sets color of the foreground.
+ *
+ * @param foregroundColor the color
+ */
+ public void setForegroundColor(
+ final eu.svjatoslav.sixth.e3d.renderer.raster.Color foregroundColor) {
+ this.foregroundColor = foregroundColor;
+ }
+
+ /**
+ * Paints the character on the screen.
+ *
+ * @param renderingContext the rendering context
+ */
+ @Override
+ public void paint(final RenderingContext renderingContext) {
+
+ // Draw background rectangle first. It is composed of two triangles.
+ drawTriangle(renderingContext,
+ vertices.get(1).onScreenCoordinate,
+ vertices.get(2).onScreenCoordinate,
+ vertices.get(3).onScreenCoordinate,
+ mouseInteractionController,
+ backgroundColor);
+
+ drawTriangle(renderingContext,
+ vertices.get(1).onScreenCoordinate,
+ vertices.get(3).onScreenCoordinate,
+ vertices.get(4).onScreenCoordinate,
+ mouseInteractionController,
+ backgroundColor);
+
+ final int desiredFontSize = (int) ((renderingContext.stereoViewportWidth * 4.5) / onScreenZ);
+
+ // do not render too large characters
+ if (desiredFontSize >= MAX_FONT_SIZE)
+ return;
+
+ final Point2D onScreenLocation = vertices.get(0).onScreenCoordinate;
+
+ // screen borders check
+ if (onScreenLocation.x < renderingContext.renderMinX)
+ return;
+ if (onScreenLocation.y < 0)
+ return;
+
+ if (onScreenLocation.x > renderingContext.renderMaxX)
+ return;
+ if (onScreenLocation.y > renderingContext.height)
+ return;
+
+ // check render Y bounds
+ if (onScreenLocation.y + desiredFontSize < renderingContext.renderMinY)
+ return;
+ if (onScreenLocation.y - desiredFontSize >= renderingContext.renderMaxY)
+ return;
+
+ // draw the character
+ final int fontSize = desiredFontSize;
+ final int drawX = (int) onScreenLocation.x - (int) (fontSize / 3.2);
+ final int drawY = (int) onScreenLocation.y + (int) (fontSize / 2.5);
+
+ renderingContext.executeWithGraphics(g -> {
+ g.setFont(getFont(fontSize));
+ g.setColor(foregroundColor.toAwtColor());
+ g.drawString(valueOf(value), drawX, drawY);
+ });
+
+ }
+
+ /**
+ * Sets the character value to render.
+ *
+ * @param value the new character value
+ */
+ public void setValue(final char value) {
+ this.value = value;
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas;
+
+/**
+ * Defines how text is rendered on a {@link TextCanvas}.
+ *
+ * <p>The render mode controls the trade-off between rendering quality and performance.
+ * {@link TextCanvas} automatically selects the optimal mode based on the viewer's
+ * distance and viewing angle relative to the text surface.</p>
+ *
+ * @see TextCanvas
+ */
+public enum RenderMode {
+ /**
+ * Text is rendered as pixels on textured polygon.
+ * This mode works in any orientation. Even if polygon is rotated.
+ */
+ TEXTURE,
+
+ /**
+ * Text is rendered as high quality, anti-aliased tiles.
+ * This mode works only if text is facing the camera almost directly.
+ */
+ CHARACTERS
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.textcanvas;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.RenderingContext;
+import eu.svjatoslav.sixth.e3d.gui.TextPointer;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.math.TransformStack;
+import eu.svjatoslav.sixth.e3d.renderer.raster.Color;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.TexturedRectangle;
+
+import java.awt.*;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+
+import static eu.svjatoslav.sixth.e3d.renderer.raster.Color.BLACK;
+import static eu.svjatoslav.sixth.e3d.renderer.raster.Color.WHITE;
+import static java.lang.Math.PI;
+import static java.lang.Math.abs;
+
+/**
+ * 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 through {@link CanvasCharacter}.</p>
+ *
+ * <p>Characters are rendered using a monospace font at a fixed size
+ * ({@value #FONT_CHAR_WIDTH_TEXTURE_PIXELS} x {@value #FONT_CHAR_HEIGHT_TEXTURE_PIXELS}
+ * texture pixels per character). The canvas automatically switches between two
+ * {@linkplain RenderMode render modes} based on the viewer's distance and viewing angle:</p>
+ * <ul>
+ * <li>{@link RenderMode#TEXTURE} -- renders all characters to a shared texture bitmap.
+ * This is efficient for distant or obliquely viewed text.</li>
+ * <li>{@link RenderMode#CHARACTERS} -- renders each character as an individual textured
+ * polygon with higher quality anti-aliased tiles. Used when the viewer is close
+ * and looking at the text nearly head-on.</li>
+ * </ul>
+ *
+ * <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 RenderMode
+ * @see CanvasCharacter
+ * @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;
+
+
+ /**
+ * The default font used for rendering text on the canvas.
+ */
+ public static final Font FONT = CanvasCharacter.getFont((int) (FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.066));
+ private static final String GROUP_TEXTURE = "texture";
+ private static final String GROUP_CHARACTERS = "characters";
+ private final TextPointer size;
+ private final TextPointer cursorLocation = new TextPointer();
+ CanvasCharacter[][] lines;
+ private RenderMode renderMode = null;
+ 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);
+ getViewSpaceTracker().enableOrientationTracking();
+
+ 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);
+
+ getTexture().primaryBitmap.fillColor(backgroundColor);
+ getTexture().resetResampledBitmapCache();
+
+ setGroupForUngrouped(GROUP_TEXTURE);
+
+ lines = new CanvasCharacter[rows][];
+ for (int row = 0; row < rows; row++) {
+ lines[row] = new CanvasCharacter[columns];
+
+ for (int column = 0; column < columns; column++) {
+ final Point3D characterCoordinate = getCharLocation(row, column);
+
+ final CanvasCharacter character = new CanvasCharacter(
+ characterCoordinate, ' ', foregroundColor,
+ backgroundColor);
+ addShape(character);
+ lines[row][column] = character;
+ }
+
+ }
+
+ setGroupForUngrouped(GROUP_CHARACTERS);
+
+ setRenderMode(RenderMode.TEXTURE);
+ }
+
+ /**
+ * 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());
+ }
+ }
+
+ @Override
+ public void beforeTransformHook(final TransformStack transformPipe,
+ final RenderingContext context) {
+ ensureOptimalRenderMode(context);
+ }
+
+ private void ensureOptimalRenderMode(RenderingContext context) {
+
+ // if the text is too far away, use texture
+ final double textRelativeSize = context.width / getViewSpaceTracker().getDistanceToCamera();
+ if (textRelativeSize < 2d) {
+ setRenderMode(RenderMode.TEXTURE);
+ return;
+ }
+
+ // if user is looking at the text from the side, use texture
+ final double piHalf = PI / 2;
+ final double deviation = abs(getViewSpaceTracker().getAngleXZ()
+ + piHalf)
+ + abs(getViewSpaceTracker().getAngleYZ() + piHalf);
+
+ final double maxDeviation = 0.5;
+ setRenderMode(deviation > maxDeviation ? RenderMode.TEXTURE : RenderMode.CHARACTERS);
+ }
+
+ /**
+ * Clears the entire canvas, resetting all characters to spaces with the default colors.
+ *
+ * <p>Both the character grid and the backing texture bitmap are reset.</p>
+ */
+ public void clear() {
+ for (final CanvasCharacter[] line : lines)
+ for (final CanvasCharacter character : line) {
+ character.setValue(' ');
+ character.setBackgroundColor(backgroundColor);
+ character.setForegroundColor(foregroundColor);
+ }
+
+ // set background color
+ getTexture().primaryBitmap.fillColor(backgroundColor);
+ getTexture().resetResampledBitmapCache();
+ }
+
+ private void drawCharToTexture(final int row, final int column,
+ final char character, final Color foreground) {
+ final Graphics2D graphics = getTexture().graphics;
+
+ getTexture().primaryBitmap.drawRectangle(
+ column * FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+ row * FONT_CHAR_HEIGHT_TEXTURE_PIXELS,
+ (column * FONT_CHAR_WIDTH_TEXTURE_PIXELS) + FONT_CHAR_WIDTH_TEXTURE_PIXELS,
+ (row * FONT_CHAR_HEIGHT_TEXTURE_PIXELS) + FONT_CHAR_HEIGHT_TEXTURE_PIXELS,
+ backgroundColor);
+
+ graphics.setFont(FONT);
+ graphics.setColor(foreground.toAwtColor());
+ graphics.drawChars(
+ new char[]{character,}, 0, 1,
+ (column * FONT_CHAR_WIDTH_TEXTURE_PIXELS),
+ (row * FONT_CHAR_HEIGHT_TEXTURE_PIXELS) + (int) (FONT_CHAR_HEIGHT_TEXTURE_PIXELS / 1.23f));
+ getTexture().resetResampledBitmapCache();
+ }
+
+ /**
+ * Computes the 3D world coordinate for the center of the character cell at the given row and column.
+ *
+ * @param row the row index (0-based, from the top)
+ * @param column the column index (0-based, from the left)
+ * @return the 3D coordinate of the character cell center, relative to the canvas origin
+ */
+ public Point3D getCharLocation(final int row, final int column) {
+ final Point3D coordinate = topLeft.clone();
+
+ coordinate.translateY((row * FONT_CHAR_HEIGHT)
+ + (FONT_CHAR_HEIGHT / 3.2));
+
+ coordinate.translateX((column * FONT_CHAR_WIDTH)
+ + (FONT_CHAR_WIDTH / 2));
+
+ return coordinate;
+ }
+
+ /**
+ * 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 >= lines.length) || (row < 0))
+ return;
+
+ final CanvasCharacter[] line = lines[row];
+
+ if ((column >= line.length) || (column < 0))
+ return;
+
+ final CanvasCharacter canvasCharacter = line[column];
+ canvasCharacter.setValue(character);
+ canvasCharacter.setBackgroundColor(backgroundColor);
+ canvasCharacter.setForegroundColor(foregroundColor);
+ 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.sixth.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.sixth.e3d.renderer.raster.Color foregroundColor) {
+ this.foregroundColor = foregroundColor;
+ }
+
+ private void setRenderMode(final RenderMode mode) {
+ if (mode == renderMode)
+ return;
+
+ switch (mode) {
+ case CHARACTERS:
+ hideGroup(GROUP_TEXTURE);
+ showGroup(GROUP_CHARACTERS);
+ break;
+ case TEXTURE:
+ hideGroup(GROUP_CHARACTERS);
+ showGroup(GROUP_TEXTURE);
+ break;
+ }
+
+ renderMode = mode;
+ }
+
+ /**
+ * 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 all existing characters on the canvas.
+ *
+ * <p>This updates the color of every {@link CanvasCharacter} in the grid,
+ * but does not affect the backing texture. It is primarily useful in
+ * {@link RenderMode#CHARACTERS} mode.</p>
+ *
+ * @param color the new foreground color for all characters
+ */
+ public void setTextColor(final Color color) {
+ for (final CanvasCharacter[] line : lines)
+ for (final CanvasCharacter character : line)
+ character.setForegroundColor(color);
+ }
+
+}
--- /dev/null
+/**
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.composite.textcanvas;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.geometry.Rectangle;
+import eu.svjatoslav.sixth.e3d.math.Transform;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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));
+ }
+
+ }
+
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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)));
+ }
+ }
+ }
+ }
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Box;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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)));
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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.sixth.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 Sixth 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.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);
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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.sixth.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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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);
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Matrix3x3;
+import eu.svjatoslav.sixth.e3d.math.Quaternion;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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.sixth.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 Sixth 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
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance;
+import eu.svjatoslav.sixth.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++;
+ }
+
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox} - A wireframe box</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube} - A wireframe cube</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeSphere} - A wireframe sphere</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.Grid2D} - A 2D grid plane</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.Grid3D} - A 3D grid volume</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.shapes.AbstractShape} - Base class for all shapes</li>
+ * <li>{@link eu.svjatoslav.sixth.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.sixth.e3d.renderer.raster.shapes.AbstractShape
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.shapes;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.tessellation;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Controls adaptive tessellation thresholds based on polygon budget.
+ *
+ * <p>This controller dynamically adjusts the screen-space tessellation threshold
+ * to keep the number of tessellated polygons approximately within a target budget.
+ * When polygon count exceeds the budget, the threshold is increased (less tessellation,
+ * larger polygons). When below budget and threshold is above minimum, it's decreased
+ * (better quality, smaller polygons).</p>
+ *
+ * <p><b>Frame lifecycle:</b></p>
+ * <ol>
+ * <li>{@link #resetFrameCount()} called at frame start to clear counter</li>
+ * <li>{@link #addTessellatedPolygons(int)} called during transform for each tessellated polygon</li>
+ * <li>{@link #endFrame()} called after painting to adjust threshold based on count</li>
+ * </ol>
+ *
+* <p><b>Adjustment logic:</b></p>
+ * <ul>
+ * <li>If polygonCount exceeds TARGET: increase threshold by INCREASE_FACTOR</li>
+ * <li>If polygonCount is below 80% of TARGET and threshold above MIN: decrease threshold</li>
+ * <li>Threshold is clamped to MIN_THRESHOLD as the lower bound</li>
+ * </ul>
+ *
+ * <p><b>Thread safety:</b> Uses volatile for threshold and AtomicInteger for count.
+ * Safe for multi-threaded rendering pipelines.</p>
+ *
+ * @see ScreenSpaceTessellator
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ */
+public class AdaptiveTessellationController {
+
+ /**
+ * Singleton instance.
+ */
+ private static final AdaptiveTessellationController INSTANCE = new AdaptiveTessellationController();
+
+ /**
+ * Minimum tessellation threshold in pixels.
+ * This is the base value from {@code TexturedTriangle.TESSELLATION_THRESHOLD_PIXELS}.
+ * Threshold will never go below this.
+ */
+ public static final double MIN_THRESHOLD = 30.0;
+
+ /**
+ * Target polygon count for tessellated triangles.
+ * The controller tries to keep actual count close to this value.
+ */
+ public static final int TARGET_POLYGON_COUNT = 1000;
+
+ /**
+ * Factor for increasing threshold when over budget.
+ * A value of 1.2 means threshold increases by 20% when too many polygons.
+ */
+ private static final double INCREASE_FACTOR = 1.2;
+
+ /**
+ * Factor for decreasing threshold when under budget.
+ * A value of 0.9 means threshold decreases by 10% when below target.
+ */
+ private static final double DECREASE_FACTOR = 0.9;
+
+ /**
+ * Minimum decrease threshold - only decrease if below 80% of target.
+ * This prevents rapid oscillation near the target boundary.
+ */
+ private static final double DECREASE_THRESHOLD_RATIO = 0.8;
+
+ /**
+ * Maximum threshold cap to prevent unbounded growth.
+ * At this threshold, polygons would be very large and texture distortion visible.
+ */
+ private static final double MAX_THRESHOLD = 300.0;
+
+ /**
+ * Current tessellation threshold in pixels.
+ * Volatile for thread-safe reads from multiple rendering threads.
+ */
+ private volatile double currentThreshold = MIN_THRESHOLD;
+
+ /**
+ * Counter for tessellated polygons during the current frame.
+ * AtomicInteger for thread-safe increments during parallel transform phase.
+ */
+ private final AtomicInteger framePolygonCount = new AtomicInteger(0);
+
+ /**
+ * Polygon count from the last frame.
+ * Used for diagnostics and debugging.
+ */
+ private volatile int lastPolygonCount = 0;
+
+ /**
+ * Returns the singleton instance.
+ *
+ * @return the adaptive tessellation controller
+ */
+ public static AdaptiveTessellationController getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Returns the current tessellation threshold in screen pixels.
+ *
+ * <p>This value is read by tessellators during the transform phase
+ * to determine when to subdivide triangles.</p>
+ *
+ * @return the current threshold in pixels
+ */
+ public double getThreshold() {
+ return currentThreshold;
+ }
+
+ /**
+ * Resets the polygon counter at the start of a new frame.
+ *
+ * <p>Called from {@code ShapeCollection.transformShapes()} before
+ * processing shapes.</p>
+ */
+ public void resetFrameCount() {
+ framePolygonCount.set(0);
+ }
+
+ /**
+ * Adds tessellated polygons to the current frame's count.
+ *
+ * <p>Called from {@code TexturedTriangle.transform()} after tessellation
+ * completes, with the number of sub-triangles produced.</p>
+ *
+ * @param count the number of tessellated polygons to add
+ */
+ public void addTessellatedPolygons(final int count) {
+ framePolygonCount.addAndGet(count);
+ }
+
+ /**
+ * Called at the end of each frame to adjust threshold based on polygon count.
+ *
+ * <p><b>Adjustment rules:</b></p>
+ * <ul>
+ * <li>Adjustment is proportional to how far count is from target (smoother convergence)</li>
+ * <li>Minimum change per frame is 2% to ensure gradual adjustment</li>
+ * <li>Maximum change per frame is 10% to prevent sudden jumps</li>
+ * <li>Threshold is always clamped between MIN_THRESHOLD and MAX_THRESHOLD</li>
+ * </ul>
+ *
+ * @return true if threshold changed significantly (should invalidate caches)
+ */
+ public boolean endFrame() {
+ final int count = framePolygonCount.get();
+ lastPolygonCount = count;
+
+ final double oldThreshold = currentThreshold;
+
+ if (count > TARGET_POLYGON_COUNT) {
+ // Too many polygons - increase threshold proportionally
+ final double ratio = (double) count / TARGET_POLYGON_COUNT;
+ // Map ratio 1.0-3.0+ to adjustment 2%-10%
+ final double adjustment = Math.min(0.10, Math.max(0.02, (ratio - 1.0) * 0.05));
+ currentThreshold = Math.min(MAX_THRESHOLD, currentThreshold * (1.0 + adjustment));
+ } else if (count < TARGET_POLYGON_COUNT * DECREASE_THRESHOLD_RATIO
+ && currentThreshold > MIN_THRESHOLD) {
+ // Below 80% of target - decrease threshold proportionally
+ final double ratio = (double) count / TARGET_POLYGON_COUNT;
+ // Map ratio 0.0-0.8 to adjustment 2%-10%
+ final double adjustment = Math.min(0.10, Math.max(0.02, (0.8 - ratio) * 0.05));
+ currentThreshold = Math.max(MIN_THRESHOLD, currentThreshold * (1.0 - adjustment));
+ }
+
+ // Return true if threshold changed (should invalidate caches)
+ return currentThreshold != oldThreshold;
+ }
+
+ /**
+ * Returns the polygon count from the last frame.
+ *
+ * @return the last frame's polygon count
+ */
+ public int getLastPolygonCount() {
+ return lastPolygonCount;
+ }
+
+ /**
+ * Returns the current frame's polygon count (so far).
+ *
+ * @return the current frame's polygon count
+ */
+ public int getCurrentFrameCount() {
+ return framePolygonCount.get();
+ }
+
+ /**
+ * Resets the threshold to the minimum value and clears counters.
+ *
+ * <p>Useful when starting a new scene or resetting renderer state.</p>
+ */
+ public void reset() {
+ currentThreshold = MIN_THRESHOLD;
+ lastPolygonCount = 0;
+ framePolygonCount.set(0);
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.tessellation;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.gui.humaninput.MouseInteractionController;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TessellatedTexturedTriangle;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+import eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Tessellates textured polygons based on screen-space edge lengths.
+ *
+ * <p>This tessellator splits triangles recursively based on how large they appear
+ * on screen (in pixels), rather than world-space dimensions. This provides
+ * per-polygon level-of-detail tessellation that adapts to camera distance naturally.</p>
+ *
+ * <p><b>Tessellation algorithm:</b></p>
+ * <ol>
+ * <li>Compute screen-space lengths of all three edges (pixels)</li>
+ * <li>If longest edge ≤ maxScreenPixels, emit the triangle as-is</li>
+ * <li>Otherwise, split the longest edge at its midpoint</li>
+ * <li>Interpolate: world coordinates, texture coordinates, screen coordinates, Z-depth</li>
+ * <li>Recurse on the two resulting sub-triangles</li>
+ * </ol>
+ *
+ * <p><b>Safeguards against excessive tessellation:</b></p>
+ * <ul>
+ * <li>Maximum recursion depth (prevents infinite/very deep recursion)</li>
+ * <li>Maximum triangles per original polygon (hard limit on output)</li>
+ * <li>Maximum screen-space edge length (skip tessellation for huge off-screen polygons)</li>
+ * </ul>
+ *
+ * <p>The resulting {@link TessellatedTexturedTriangle} instances have pre-computed screen
+ * coordinates, avoiding redundant transformation during the render phase.</p>
+ *
+ * @see TessellatedTexturedTriangle
+ * @see TexturedTriangle
+ */
+public class ScreenSpaceTessellator {
+
+ private static final ScreenSpaceEdge edge1 = new ScreenSpaceEdge();
+ private static final ScreenSpaceEdge edge2 = new ScreenSpaceEdge();
+ private static final ScreenSpaceEdge edge3 = new ScreenSpaceEdge();
+
+ /**
+ * Maximum recursion depth for tessellation.
+ * Prevents stack overflow and excessive subdivision.
+ * 8 levels = 256 triangles max per original polygon.
+ */
+ private static final int MAX_RECURSION_DEPTH = 8;
+
+ /**
+ * Maximum number of tessellated triangles to produce per original polygon.
+ * Acts as a hard safety limit to prevent memory explosion.
+ */
+ private static final int MAX_TRIANGLES_PER_POLYGON = 600;
+
+ /**
+ * Maximum screen-space edge length to even consider tessellation.
+ * Edges larger than this indicate the polygon is mostly off-screen or
+ * the camera is extremely close - tessellation would produce invisible triangles.
+ * Set to 10000 pixels (roughly 4K screen diagonal * 2).
+ */
+ private static final double MAX_EDGE_LENGTH_FOR_TESSELLATION = 10000.0;
+
+ /**
+ * Maximum screen-space edge length in pixels.
+ * Edges longer than this will be subdivided.
+ *
+ * This value is mutable to support adaptive tessellation - it can be
+ * updated from {@link AdaptiveTessellationController} before each use.
+ */
+ private double maxScreenPixels;
+
+ /**
+ * Current recursion depth (reset for each tessellate() call).
+ */
+ private int currentDepth = 0;
+
+ /**
+ * Mouse interaction controller to propagate to tessellated triangles.
+ * Set from the original triangle during tessellate() and passed to all sub-triangles.
+ */
+ private MouseInteractionController mouseInteractionController;
+
+ /**
+ * Resulting tessellated triangles.
+ */
+ private final List<TessellatedTexturedTriangle> result = new ArrayList<>();
+
+ /**
+ * Creates a tessellator with the specified maximum screen-space edge length.
+ *
+ * @param maxScreenPixels the maximum allowed edge length in screen pixels;
+ * edges longer than this will be subdivided
+ */
+ public ScreenSpaceTessellator(final double maxScreenPixels) {
+ this.maxScreenPixels = maxScreenPixels;
+ }
+
+ /**
+ * Updates the maximum screen-space edge length threshold.
+ *
+ * <p>This method allows adaptive tessellation control by updating the threshold
+ * before each tessellation operation. Called from {@link AdaptiveTessellationController}.</p>
+ *
+ * @param maxScreenPixels the new maximum allowed edge length in screen pixels
+ */
+ public void setMaxScreenPixels(final double maxScreenPixels) {
+ this.maxScreenPixels = maxScreenPixels;
+ }
+
+ /**
+ * Returns the list of tessellated triangles.
+ *
+ * @return the resulting tessellated triangles
+ */
+ public List<TessellatedTexturedTriangle> getResult() {
+ return result;
+ }
+
+ /**
+ * Tessellates the given textured triangle into smaller triangles.
+ *
+ * <p>After calling this method, retrieve the resulting sub-triangles via
+ * {@link #getResult()}. The original triangle's texture reference and
+ * backface culling settings are preserved on all sub-triangles.</p>
+ *
+ * <p>Safeguards prevent excessive tessellation when the polygon is extremely
+ * large on screen (e.g., when camera is very close).</p>
+ *
+ * @param original the triangle to tessellate (must have screen coords computed)
+ */
+ public void tessellate(final TexturedTriangle original) {
+ result.clear();
+ currentDepth = 0;
+ mouseInteractionController = original.mouseInteractionController;
+
+ final Vertex v1 = original.vertices.get(0);
+ final Vertex v2 = original.vertices.get(1);
+ final Vertex v3 = original.vertices.get(2);
+
+ tessellateRecursively(
+ v1, v2, v3,
+ v1.onScreenCoordinate, v2.onScreenCoordinate, v3.onScreenCoordinate,
+ original.texture,
+ original.isBackfaceCullingEnabled()
+ );
+ }
+
+ /**
+ * Recursively tessellates a triangle based on screen-space edge lengths.
+ *
+ * @param c1 first vertex (world + texture coords)
+ * @param c2 second vertex (world + texture coords)
+ * @param c3 third vertex (world + texture coords)
+ * @param screen1 screen coordinate of first vertex
+ * @param screen2 screen coordinate of second vertex
+ * @param screen3 screen coordinate of third vertex
+ * @param texture the texture to apply
+ * @param backfaceCull whether to enable backface culling on sub-triangles
+ */
+ private void tessellateRecursively(final Vertex c1, final Vertex c2, final Vertex c3,
+ final Point2D screen1, final Point2D screen2,
+ final Point2D screen3,
+ final Texture texture, final boolean backfaceCull) {
+
+ // Safety check: maximum triangles reached - emit as-is and stop
+ if (result.size() >= MAX_TRIANGLES_PER_POLYGON) {
+ emitTriangle(c1, c2, c3, texture, backfaceCull);
+ return;
+ }
+
+ edge1.set(c1, c2, screen1, screen2, 1);
+ edge2.set(c2, c3, screen2, screen3, 2);
+ edge3.set(c3, c1, screen3, screen1, 3);
+
+ // Inline sort of 3 edges by screen-space length
+ ScreenSpaceEdge a = edge1;
+ ScreenSpaceEdge b = edge2;
+ ScreenSpaceEdge c = edge3;
+ ScreenSpaceEdge t;
+
+ if (a.getScreenLength() > b.getScreenLength()) {
+ t = a;
+ a = b;
+ b = t;
+ }
+ if (b.getScreenLength() > c.getScreenLength()) {
+ t = b;
+ b = c;
+ c = t;
+ }
+ if (a.getScreenLength() > b.getScreenLength()) {
+ t = a;
+ a = b;
+ b = t;
+ }
+
+ final ScreenSpaceEdge longestEdge = c;
+
+ // Safety check: polygon is way too large (off-screen or extreme close-up)
+ // Skip tessellation entirely to prevent generating millions of invisible triangles
+ if (longestEdge.getScreenLength() > MAX_EDGE_LENGTH_FOR_TESSELLATION) {
+ emitTriangle(c1, c2, c3, texture, backfaceCull);
+ return;
+ }
+
+ // If longest edge is within threshold, emit triangle
+ if (longestEdge.getScreenLength() <= maxScreenPixels) {
+ emitTriangle(c1, c2, c3, texture, backfaceCull);
+ return;
+ }
+
+ // Safety check: max recursion depth reached - emit and stop
+ if (currentDepth >= MAX_RECURSION_DEPTH) {
+ emitTriangle(c1, c2, c3, texture, backfaceCull);
+ return;
+ }
+
+ // Split longest edge at midpoint
+ final Vertex midpoint = longestEdge.interpolateMidpoint();
+ final Point2D midpointScreen = longestEdge.interpolateScreenMidpoint();
+
+ currentDepth++;
+
+ // Recurse on two sub-triangles based on which edge was longest
+ switch (longestEdge.edgeId) {
+ case 1:
+ // Edge 1 was c1→c2
+ tessellateRecursively(c1, midpoint, c3, screen1, midpointScreen, screen3,
+ texture, backfaceCull);
+ tessellateRecursively(midpoint, c2, c3, midpointScreen, screen2, screen3,
+ texture, backfaceCull);
+ break;
+ case 2:
+ // Edge 2 was c2→c3
+ tessellateRecursively(c1, c2, midpoint, screen1, screen2, midpointScreen,
+ texture, backfaceCull);
+ tessellateRecursively(c1, midpoint, c3, screen1, midpointScreen, screen3,
+ texture, backfaceCull);
+ break;
+ case 3:
+ // Edge 3 was c3→c1
+ tessellateRecursively(c1, c2, midpoint, screen1, screen2, midpointScreen,
+ texture, backfaceCull);
+ tessellateRecursively(midpoint, c2, c3, midpointScreen, screen2, screen3,
+ texture, backfaceCull);
+ break;
+ }
+
+ currentDepth--;
+ }
+
+ /**
+ * Emits a tessellated triangle to the result list.
+ */
+ private void emitTriangle(final Vertex c1, final Vertex c2, final Vertex c3,
+ final Texture texture, final boolean backfaceCull) {
+ final TessellatedTexturedTriangle triangle = new TessellatedTexturedTriangle(
+ c1.cloneWithScreenCoords(),
+ c2.cloneWithScreenCoords(),
+ c3.cloneWithScreenCoords(),
+ texture
+ );
+ triangle.setBackfaceCulling(backfaceCull);
+ triangle.setMouseInteractionController(mouseInteractionController);
+
+ // Compute average Z-depth for proper depth sorting
+ triangle.onScreenZ = (c1.transformedCoordinate.z + c2.transformedCoordinate.z + c3.transformedCoordinate.z) / 3.0;
+
+ result.add(triangle);
+ }
+
+ /**
+ * Represents an edge with screen-space length for tessellation decisions.
+ */
+ private static class ScreenSpaceEdge {
+
+ Vertex v1;
+ Vertex v2;
+ Point2D screen1;
+ Point2D screen2;
+ int edgeId;
+
+ void set(final Vertex v1, final Vertex v2,
+ final Point2D screen1, final Point2D screen2,
+ final int edgeId) {
+ this.v1 = v1;
+ this.v2 = v2;
+ this.screen1 = screen1;
+ this.screen2 = screen2;
+ this.edgeId = edgeId;
+ }
+
+ /**
+ * Returns the screen-space length of this edge in pixels.
+ */
+ double getScreenLength() {
+ final double dx = screen2.x - screen1.x;
+ final double dy = screen2.y - screen1.y;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ /**
+ * Interpolates a vertex at the midpoint of this edge.
+ *
+ * <p>Interpolates world coordinates, texture coordinates, transformed coordinates,
+ * and screen coordinates.</p>
+ *
+ * @return a new vertex at the midpoint with all interpolated properties
+ */
+ Vertex interpolateMidpoint() {
+ final Point3D worldMid = new Point3D().computeMiddlePoint(v1.coordinate, v2.coordinate);
+ final Point2D texMid = new Point2D().setToMiddle(v1.textureCoordinate, v2.textureCoordinate);
+
+ final Vertex result = new Vertex(worldMid, texMid);
+
+ result.transformedCoordinate = new Point3D(
+ (v1.transformedCoordinate.x + v2.transformedCoordinate.x) / 2,
+ (v1.transformedCoordinate.y + v2.transformedCoordinate.y) / 2,
+ (v1.transformedCoordinate.z + v2.transformedCoordinate.z) / 2
+ );
+
+ result.onScreenCoordinate = new Point2D(
+ (screen1.x + screen2.x) / 2,
+ (screen1.y + screen2.y) / 2
+ );
+
+ return result;
+ }
+
+ /**
+ * Returns the screen-space midpoint of this edge.
+ */
+ Point2D interpolateScreenMidpoint() {
+ return new Point2D(
+ (screen1.x + screen2.x) / 2,
+ (screen1.y + screen2.y) / 2
+ );
+ }
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.tessellation;
+
+import eu.svjatoslav.sixth.e3d.geometry.Point2D;
+import eu.svjatoslav.sixth.e3d.geometry.Point3D;
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+
+/**
+ * Represents an edge of a triangle in the tessellation pipeline.
+ *
+ * <p>A {@code TessellationEdge} connects two {@link Vertex} endpoints and carries an
+ * identification {@link #count} used by the {@link TexturedPolygonTessellator} to determine which edge
+ * of the original triangle this edge corresponds to (1, 2, or 3). This identification
+ * is essential for correct recursive tessellation -- when the longest edge is split,
+ * the tessellator uses the count to decide how to partition the triangle into two
+ * smaller triangles.</p>
+ *
+ * @see TexturedPolygonTessellator
+ * @see Vertex
+ */
+public class TessellationEdge {
+
+ /**
+ * The edge identifier (1, 2, or 3) indicating which edge of the original triangle
+ * this tessellation edge represents. Used by {@link TexturedPolygonTessellator} during recursive tessellation.
+ */
+ public int count;
+
+ /**
+ * The first vertex endpoint of this edge.
+ */
+ Vertex c1;
+
+ /**
+ * The second vertex endpoint of this edge.
+ */
+ Vertex c2;
+
+ /**
+ * Creates an uninitialized tessellation edge for reuse.
+ */
+ public TessellationEdge() {
+ }
+
+ /**
+ * Sets the endpoints and edge identifier for this tessellation edge.
+ *
+ * @param c1 the first vertex endpoint
+ * @param c2 the second vertex endpoint
+ * @param count the edge identifier (1, 2, or 3)
+ */
+ public void set(final Vertex c1, final Vertex c2, final int count) {
+ this.c1 = c1;
+ this.c2 = c2;
+ this.count = count;
+ }
+
+ /**
+ * Computes the 3D Euclidean distance between the two endpoint vertices.
+ *
+ * @return the length of this edge in world-space units
+ */
+ public double getLength() {
+ return c1.coordinate.getDistanceTo(c2.coordinate);
+ }
+
+ /**
+ * Computes the midpoint vertex of this edge by averaging both the 3D coordinates
+ * and the 2D texture coordinates of the two endpoints.
+ *
+ * @return a new {@link Vertex} at the midpoint, with interpolated texture coordinates
+ */
+ public Vertex getMiddlePoint() {
+ return new Vertex(
+ new Point3D().computeMiddlePoint(c1.coordinate, c2.coordinate),
+ new Point2D().setToMiddle(c1.textureCoordinate, c2.textureCoordinate));
+ }
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.tessellation;
+
+import eu.svjatoslav.sixth.e3d.math.Vertex;
+import eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Recursively tessellates textured polygons into smaller triangles for
+ * perspective-correct rendering and level-of-detail management.
+ *
+ * <p>When a textured polygon covers a large area of the screen, rendering it as
+ * a single triangle can produce visible texture distortion due to affine (non-perspective)
+ * texture interpolation. The {@code TexturedPolygonTessellator} addresses this by recursively splitting
+ * triangles along their longest edge until no edge exceeds {@link #maxDistance}.</p>
+ *
+ * <p>The tessellation algorithm works as follows:</p>
+ * <ol>
+ * <li>For a given triangle, compute the lengths of all three edges.</li>
+ * <li>Sort edges by length and find the longest one.</li>
+ * <li>If the longest edge is shorter than {@code maxDistance}, emit the triangle as-is.</li>
+ * <li>Otherwise, split the longest edge at its midpoint (interpolating both 3D and
+ * texture coordinates) and recurse on the two resulting sub-triangles.</li>
+ * </ol>
+ *
+ * <p>This class is used by
+ * {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape}
+ * to break large composite shapes into appropriately-sized sub-polygons.</p>
+ *
+ * @see TessellationEdge
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ */
+public class TexturedPolygonTessellator {
+
+ private static final TessellationEdge edge1 = new TessellationEdge();
+ private static final TessellationEdge edge2 = new TessellationEdge();
+ private static final TessellationEdge edge3 = new TessellationEdge();
+
+ /**
+ * Maximum distance between two points.
+ * If the distance is greater than this value, the polygon will be tessellated.
+ * Otherwise, it will be added to the result.
+ */
+ private final double maxDistance;
+
+ /**
+ * Result of tessellation.
+ */
+ private final List<TexturedTriangle> result = new ArrayList<>();
+
+ /**
+ * Creates a new tessellator with the specified maximum edge length.
+ *
+ * @param maxDistance the maximum allowed edge length in world-space units;
+ * edges longer than this will be subdivided
+ */
+ public TexturedPolygonTessellator(final double maxDistance) {
+ this.maxDistance = maxDistance;
+ }
+
+ private void tessellateRecursively(final Vertex c1,
+ final Vertex c2,
+ final Vertex c3,
+ final TexturedTriangle originalPolygon) {
+
+ edge1.set(c1, c2, 1);
+ edge2.set(c2, c3, 2);
+ edge3.set(c3, c1, 3);
+
+ // Inline sort for 3 elements by length to avoid array allocation
+ TessellationEdge a = edge1;
+ TessellationEdge b = edge2;
+ TessellationEdge c = edge3;
+ TessellationEdge t;
+ if (a.getLength() > b.getLength()) {
+ t = a;
+ a = b;
+ b = t;
+ }
+ if (b.getLength() > c.getLength()) {
+ t = b;
+ b = c;
+ c = t;
+ }
+ if (a.getLength() > b.getLength()) {
+ t = a;
+ a = b;
+ b = t;
+ }
+
+ final TessellationEdge longestEdge = c;
+
+ if (longestEdge.getLength() < maxDistance) {
+ final TexturedTriangle polygon = new TexturedTriangle(c1, c2, c3,
+ originalPolygon.texture);
+
+ polygon.setMouseInteractionController(originalPolygon.mouseInteractionController);
+
+ getResult().add(polygon);
+ return;
+ }
+
+ final Vertex middle = longestEdge.getMiddlePoint();
+
+ switch (longestEdge.count) {
+ case 1:
+ tessellateRecursively(c1, middle, c3, originalPolygon);
+ tessellateRecursively(middle, c2, c3, originalPolygon);
+ return;
+ case 2:
+ tessellateRecursively(c1, c2, middle, originalPolygon);
+ tessellateRecursively(middle, c3, c1, originalPolygon);
+ return;
+ case 3:
+ tessellateRecursively(c1, c2, middle, originalPolygon);
+ tessellateRecursively(middle, c2, c3, originalPolygon);
+ }
+
+ }
+
+ /**
+ * Returns the list of tessellated polygons produced by the tessellation process.
+ *
+ * @return an unmodifiable view of the resulting {@link TexturedTriangle} list
+ */
+ public List<TexturedTriangle> getResult() {
+ return result;
+ }
+
+ /**
+ * Tessellates the given textured polygon into smaller triangles.
+ *
+ * <p>After calling this method, retrieve the resulting sub-polygons via
+ * {@link #getResult()}. The original polygon's texture reference and
+ * mouse interaction controller are preserved on all sub-polygons.</p>
+ *
+ * @param originalPolygon the polygon to tessellate
+ */
+ public void tessellate(final TexturedTriangle originalPolygon) {
+
+ tessellateRecursively(
+ originalPolygon.vertices.get(0),
+ originalPolygon.vertices.get(1),
+ originalPolygon.vertices.get(2),
+ originalPolygon);
+ }
+
+}
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Triangle tessellation for perspective-correct texture rendering.
+ *
+ * <p>Large textured triangles are tessellated into smaller triangles to ensure
+ * accurate perspective correction. This package provides two tessellation algorithms:</p>
+ *
+ * <ul>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.ScreenSpaceTessellator} -
+ * Screen-space tessellation based on pixel edge lengths (per-polygon LOD)</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator} -
+ * World-space tessellation based on distance (legacy, used for CSG operations)</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.ScreenSpaceTessellator
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TexturedPolygonTessellator
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.tessellation.TessellationEdge
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.tessellation;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle
+ */
+public class Texture {
+
+ /**
+ * 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
+
+ /**
+ * 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.sixth.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 < 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 > 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;
+ }
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.texture;
+
+import eu.svjatoslav.sixth.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;
+ }
+}
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.e3d.renderer.raster.texture;
+
+import eu.svjatoslav.sixth.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
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.renderer.raster.texture.Texture} - Main texture class with mipmap support</li>
+ * <li>{@link eu.svjatoslav.sixth.e3d.renderer.raster.texture.TextureBitmap} - Raw pixel data for a single mipmap level</li>
+ * </ul>
+ *
+ * @see eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture
+ */
+
+package eu.svjatoslav.sixth.e3d.renderer.raster.texture;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth - System for data storage, computation, exploration and interaction.
+ * Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ *
+*/
+
+package eu.svjatoslav.sixth.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());
+ }
+
+}
--- /dev/null
+/*
+ * Sixth 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.sixth.e3d.gui.textEditorComponent.TextLine}
+ * and related text processing functionality.</p>
+ */
+
+package eu.svjatoslav.sixth.e3d.gui.textEditorComponent;
\ No newline at end of file
--- /dev/null
+/*
+ * Sixth 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.sixth.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