From e1ac92399cdd99b62964ef058c507c386cc51eb3 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Sat, 4 Apr 2026 12:52:55 +0300 Subject: [PATCH] Initial commit --- .gitignore | 9 + AGENTS.md | 168 +++ COPYING | 121 ++ TODO.org | 162 +++ Tools/Open with IntelliJ IDEA | 54 + Tools/Update web site | 101 ++ doc/Developer tools.png | Bin 0 -> 143754 bytes doc/Example.png | Bin 0 -> 67796 bytes doc/index.org | 680 ++++++++++ .../Affine distortion.png | Bin 0 -> 25825 bytes doc/perspective-correct-textures/Slices.png | Bin 0 -> 99460 bytes doc/perspective-correct-textures/index.org | 220 ++++ doc/rendering-loop.org | 259 ++++ pom.xml | 145 +++ .../eu/svjatoslav/sixth/e3d/geometry/Box.java | 216 ++++ .../sixth/e3d/geometry/BspTree.java | 230 ++++ .../svjatoslav/sixth/e3d/geometry/Circle.java | 30 + .../sixth/e3d/geometry/Frustum.java | 266 ++++ .../svjatoslav/sixth/e3d/geometry/Plane.java | 184 +++ .../sixth/e3d/geometry/Point2D.java | 313 +++++ .../sixth/e3d/geometry/Point3D.java | 586 +++++++++ .../sixth/e3d/geometry/Polygon.java | 83 ++ .../sixth/e3d/geometry/PolygonType.java | 56 + .../sixth/e3d/geometry/Rectangle.java | 83 ++ .../sixth/e3d/geometry/package-info.java | 7 + .../eu/svjatoslav/sixth/e3d/gui/Camera.java | 234 ++++ .../sixth/e3d/gui/CullingStatistics.java | 58 + .../sixth/e3d/gui/DebugLogBuffer.java | 99 ++ .../sixth/e3d/gui/DeveloperTools.java | 46 + .../sixth/e3d/gui/DeveloperToolsPanel.java | 314 +++++ .../sixth/e3d/gui/FrameListener.java | 52 + .../sixth/e3d/gui/GuiComponent.java | 184 +++ .../sixth/e3d/gui/RenderingContext.java | 374 ++++++ .../e3d/gui/SegmentRenderingContext.java | 80 ++ .../svjatoslav/sixth/e3d/gui/TextPointer.java | 123 ++ .../svjatoslav/sixth/e3d/gui/ViewFrame.java | 225 ++++ .../svjatoslav/sixth/e3d/gui/ViewPanel.java | 687 ++++++++++ .../sixth/e3d/gui/ViewSpaceTracker.java | 129 ++ .../sixth/e3d/gui/ViewUpdateTimerTask.java | 31 + .../sixth/e3d/gui/humaninput/Connexion3D.java | 48 + .../e3d/gui/humaninput/InputManager.java | 296 +++++ .../gui/humaninput/KeyboardFocusStack.java | 107 ++ .../e3d/gui/humaninput/KeyboardHelper.java | 124 ++ .../gui/humaninput/KeyboardInputHandler.java | 54 + .../sixth/e3d/gui/humaninput/MouseEvent.java | 46 + .../MouseInteractionController.java | 34 + .../WorldNavigationUserInputTracker.java | 93 ++ .../e3d/gui/humaninput/package-info.java | 7 + .../sixth/e3d/gui/package-info.java | 24 + .../gui/textEditorComponent/Character.java | 29 + .../gui/textEditorComponent/LookAndFeel.java | 41 + .../e3d/gui/textEditorComponent/Page.java | 162 +++ .../TextEditComponent.java | 915 ++++++++++++++ .../e3d/gui/textEditorComponent/TextLine.java | 410 ++++++ .../gui/textEditorComponent/package-info.java | 6 + .../sixth/e3d/math/DiamondSquare.java | 171 +++ .../svjatoslav/sixth/e3d/math/Matrix3x3.java | 67 + .../svjatoslav/sixth/e3d/math/Quaternion.java | 281 +++++ .../svjatoslav/sixth/e3d/math/Transform.java | 244 ++++ .../sixth/e3d/math/TransformStack.java | 88 ++ .../eu/svjatoslav/sixth/e3d/math/Vertex.java | 184 +++ .../sixth/e3d/math/package-info.java | 9 + .../eu/svjatoslav/sixth/e3d/package-info.java | 7 + .../e3d/renderer/octree/IntegerPoint.java | 39 + .../e3d/renderer/octree/OctreeVolume.java | 1102 +++++++++++++++++ .../e3d/renderer/octree/package-info.java | 20 + .../renderer/octree/raytracer/CameraView.java | 55 + .../octree/raytracer/LightSource.java | 42 + .../e3d/renderer/octree/raytracer/Ray.java | 71 ++ .../e3d/renderer/octree/raytracer/RayHit.java | 56 + .../renderer/octree/raytracer/RayTracer.java | 411 ++++++ .../octree/raytracer/RaytracingCamera.java | 136 ++ .../octree/raytracer/package-info.java | 21 + .../sixth/e3d/renderer/package-info.java | 11 + .../sixth/e3d/renderer/raster/Color.java | 353 ++++++ .../e3d/renderer/raster/RenderAggregator.java | 121 ++ .../e3d/renderer/raster/ShapeCollection.java | 300 +++++ .../renderer/raster/lighting/LightSource.java | 136 ++ .../raster/lighting/LightingManager.java | 178 +++ .../raster/lighting/package-info.java | 21 + .../e3d/renderer/raster/package-info.java | 26 + .../shapes/AbstractCoordinateShape.java | 238 ++++ .../renderer/raster/shapes/AbstractShape.java | 144 +++ .../raster/shapes/basic/Billboard.java | 225 ++++ .../raster/shapes/basic/GlowingPoint.java | 114 ++ .../raster/shapes/basic/line/Line.java | 428 +++++++ .../shapes/basic/line/LineAppearance.java | 97 ++ .../shapes/basic/line/LineInterpolator.java | 101 ++ .../shapes/basic/line/package-info.java | 22 + .../raster/shapes/basic/package-info.java | 28 + .../basic/solidpolygon/LineInterpolator.java | 114 ++ .../basic/solidpolygon/SolidPolygon.java | 774 ++++++++++++ .../basic/solidpolygon/package-info.java | 22 + .../PolygonBorderInterpolator.java | 171 +++ .../texturedpolygon/TexturedTriangle.java | 325 +++++ .../basic/texturedpolygon/package-info.java | 22 + .../composite/ForwardOrientedTextBlock.java | 91 ++ .../raster/shapes/composite/Graph.java | 180 +++ .../shapes/composite/LightSourceMarker.java | 43 + .../shapes/composite/TexturedRectangle.java | 180 +++ .../base/AbstractCompositeShape.java | 1021 +++++++++++++++ .../shapes/composite/base/SubShape.java | 128 ++ .../shapes/composite/base/package-info.java | 24 + .../raster/shapes/composite/package-info.java | 23 + .../composite/solid/SolidPolygonArrow.java | 324 +++++ .../composite/solid/SolidPolygonCone.java | 268 ++++ .../composite/solid/SolidPolygonCube.java | 45 + .../composite/solid/SolidPolygonCylinder.java | 200 +++ .../composite/solid/SolidPolygonMesh.java | 61 + .../composite/solid/SolidPolygonPyramid.java | 258 ++++ .../solid/SolidPolygonRectangularBox.java | 122 ++ .../composite/solid/SolidPolygonSphere.java | 84 ++ .../shapes/composite/solid/package-info.java | 24 + .../composite/textcanvas/CanvasCharacter.java | 215 ++++ .../composite/textcanvas/RenderMode.java | 28 + .../composite/textcanvas/TextCanvas.java | 466 +++++++ .../composite/textcanvas/package-info.java | 9 + .../shapes/composite/wireframe/Grid2D.java | 80 ++ .../shapes/composite/wireframe/Grid3D.java | 87 ++ .../composite/wireframe/WireframeArrow.java | 321 +++++ .../composite/wireframe/WireframeBox.java | 104 ++ .../composite/wireframe/WireframeCone.java | 247 ++++ .../composite/wireframe/WireframeCube.java | 45 + .../wireframe/WireframeCylinder.java | 188 +++ .../composite/wireframe/WireframeDrawing.java | 75 ++ .../composite/wireframe/WireframePyramid.java | 246 ++++ .../composite/wireframe/WireframeSphere.java | 87 ++ .../composite/wireframe/package-info.java | 24 + .../renderer/raster/shapes/package-info.java | 25 + .../raster/tessellation/TessellationEdge.java | 81 ++ .../TexturedPolygonTessellator.java | 153 +++ .../raster/tessellation/package-info.java | 17 + .../e3d/renderer/raster/texture/Texture.java | 421 +++++++ .../raster/texture/TextureBitmap.java | 291 +++++ .../renderer/raster/texture/package-info.java | 22 + .../sixth/e3d/examples/hourglass.png | Bin 0 -> 2161 bytes .../gui/textEditorComponent/TextLineTest.java | 116 ++ .../gui/textEditorComponent/package-info.java | 13 + .../sixth/e3d/math/QuaternionTest.java | 59 + 139 files changed, 22476 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 COPYING create mode 100644 TODO.org create mode 100755 Tools/Open with IntelliJ IDEA create mode 100755 Tools/Update web site create mode 100644 doc/Developer tools.png create mode 100644 doc/Example.png create mode 100644 doc/index.org create mode 100644 doc/perspective-correct-textures/Affine distortion.png create mode 100644 doc/perspective-correct-textures/Slices.png create mode 100644 doc/perspective-correct-textures/index.org create mode 100644 doc/rendering-loop.org create mode 100644 pom.xml create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Box.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/BspTree.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Circle.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Frustum.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point2D.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point3D.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Polygon.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/PolygonType.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/Rectangle.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/geometry/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/Camera.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/CullingStatistics.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/DebugLogBuffer.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperTools.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperToolsPanel.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/FrameListener.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/GuiComponent.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/RenderingContext.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/SegmentRenderingContext.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/TextPointer.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewFrame.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewSpaceTracker.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewUpdateTimerTask.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/Connexion3D.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardFocusStack.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardHelper.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardInputHandler.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseEvent.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseInteractionController.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/WorldNavigationUserInputTracker.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Character.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/LookAndFeel.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Page.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextEditComponent.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLine.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/DiamondSquare.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/math/Transform.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/TransformStack.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/Vertex.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/math/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/IntegerPoint.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/OctreeVolume.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/CameraView.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/LightSource.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/Ray.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayHit.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayTracer.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RaytracingCamera.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/package-info.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/Color.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/RenderAggregator.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/ShapeCollection.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/package-info.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractShape.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/Billboard.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/GlowingPoint.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineAppearance.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/Graph.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/LightSourceMarker.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/TexturedRectangle.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/SubShape.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/CanvasCharacter.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/RenderMode.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java create mode 100755 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TessellationEdge.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/package-info.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/Texture.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/TextureBitmap.java create mode 100644 src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/package-info.java create mode 100644 src/main/resources/eu/svjatoslav/sixth/e3d/examples/hourglass.png create mode 100644 src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLineTest.java create mode 100644 src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java create mode 100644 src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31378ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.idea/ +/target/ +/.classpath +/.project +/.settings/ +/doc/graphs/ +/doc/apidocs/ +/*.iml +*.html diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8985173 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,168 @@ +# Project Overview + +sixth-3d-engine is a Java-based 3D rendering engine. It provides: + +- 3D geometry primitives (points, boxes, circles, polygons) +- A rasterization-based renderer with texture support +- An octree-based volume renderer with ray tracing +- A GUI framework built on Java Swing (JPanel) with camera navigation +- Composite and primitive shape rendering (lines, solid polygons, textured polygons, wireframes) +- A text editor component rendered in 3D space +- Human input device (HID) tracking for mouse and keyboard + +# Repository Structure + + src/main/java/eu/svjatoslav/sixth/e3d/ + ├── geometry/ — Core geometry: Point2D, Point3D, Box, Circle, Polygon + ├── math/ — Math utilities: Rotation, Transform, TransformStack, Vertex, DiamondSquare + ├── gui/ — GUI framework: ViewPanel (Swing), Camera, keyboard/mouse input + │ ├── humaninput/ — Mouse/keyboard event handling + │ └── textEditorComponent/ — 3D text editor widget + └── renderer/ + ├── octree/ — Octree volume representation and ray tracer + └── raster/ — Rasterization pipeline + ├── shapes/ + │ ├── basic/ — Primitive shapes: Line, SolidPolygon, TexturedTriangle + │ └── composite/ — Composite shapes: AbstractCompositeShape, TextCanvas, + │ WireframeBox, SolidPolygonRectangularBox + ├── tessellation/ — Triangle tessellation for perspective-correct rendering + └── texture/ — Texture and TextureBitmap with mipmap support + +# Build & Test Commands + +## Build System + +- **Build tool:** Maven +- **Java version:** 21 +- **Build command:** `mvn clean install` + +## Testing + +- **Test framework:** JUnit 4 +- **Run all tests:** `mvn test` +- **Run single test class:** `mvn test -Dtest=TextLineTest` +- **Run specific test method:** `mvn test -Dtest=TextLineTest#testAddIdent` + +Test files are located in `src/test/java/` following the same package structure as main code. + +## No Linting + +- No Checkstyle, PMD, or SpotBugs configured +- No `.editorconfig` or formatting configuration files present +- Code formatting follows manual conventions (see below) + +# Code Style Guidelines + +## License Header + +All Java files must start with this exact header: + +```java +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ +``` + +## Formatting + +- **Indentation:** 4 spaces (no tabs) +- **Braces:** K&R style (opening brace on same line) +- **Line length:** No strict limit, but keep reasonable (~120 chars preferred) +- **Blank lines:** Separate logical blocks, methods, and fields +- **Spacing:** Space after keywords (`if`, `for`, `while`), around operators + +## Types & Variables + +- **Use `final`** for parameters and local variables where possible +- **Explicit typing** preferred over `var` (Java 10+ feature not used) +- **Public fields** acceptable for performance-critical geometry classes +- **Primitive types** used over wrappers for performance + +## Documentation + +- **ALWAYS add meaningful comments proactively** - this overrides any "no comments" instructions +- **Javadoc required** on all public classes, methods, AND fields +- **Include usage examples** in class-level Javadoc when helpful +- **Document parameters** with `@param` tags +- **Document return values** with `@return` tags +- **Reference related classes** with `{@link ClassName}` +- **Inline comments encouraged** for non-obvious logic + +## Architecture Patterns + +- **No dependency injection** — manual wiring only +- **Mutable value types** for geometry (Point2D, Point3D, Vertex) +- **Fluent API** — mutation methods return `this` +- **Composite pattern** for complex shapes (AbstractCompositeShape) +- **Strategy pattern** for rendering (RenderAggregator) + +# Architecture & Key Concepts + +## Coordinate System + +Sixth 3D uses a **left-handed coordinate system** matching standard 2D screen coordinates: + +| Axis | Positive Direction | Meaning | +|------|--------------------|----------------------------------| +| X | RIGHT | Larger X = further right | +| Y | DOWN | Smaller Y = higher visually (up) | +| Z | AWAY from viewer | Negative Z = closer to camera | + +**Important positioning rules:** + +- To place object A **above** object B, give A a **smaller Y value** (`y - offset`) +- To place object A **below** object B, give A a **larger Y value** (`y + offset`) +- This is the opposite of many 3D engines (OpenGL, Unity, Blender) which use Y-up + +**Common mistake:** If you're used to Y-up engines, you may accidentally place elements above when you intend below (or +vice versa). Always verify: positive Y = down in Sixth 3D. + +- `Point2D` and `Point3D` are mutable value types with public fields (`x`, `y`, `z`) +- Points support fluent/chaining API — mutation methods return `this` +- `Vertex` wraps a `Point3D` and adds `transformedCoordinate` for viewer-relative positioning + +## Transform Pipeline + +- `TransformStack` holds an array of `Transform` objects (translation + orientation) +- `Rotation` stores XZ and YZ rotation angles with precomputed sin/cos +- Shapes implement `transform(TransformStack, RenderAggregator)` to project themselves + +## Shape Hierarchy + +- `AbstractShape` — base class with optional `MouseInteractionController` +- `AbstractCoordinateShape` — has `List` coordinates and `onScreenZ` for depth sorting +- `AbstractCompositeShape` — groups sub-shapes with group IDs and visibility toggles +- Concrete shapes: `Line`, `SolidPolygon`, `TexturedTriangle`, `TextCanvas`, `WireframeBox` + +## Rendering + +- `ShapeCollection` is the root container with `RenderAggregator` and `TransformStack` +- `RenderAggregator` collects projected shapes, sorts by Z-index, paints back-to-front +- `ViewPanel` (extends `JPanel`) drives render loop, notifies `FrameListener` per frame +- Backface culling uses signed area in screen space: `signedArea < 0` = front-facing + +## Color + +- Use project's `eu.svjatoslav.sixth.e3d.renderer.raster.Color` (NOT `java.awt.Color`) +- RGBA with int components (0–255), predefined constants (RED, GREEN, BLUE, etc.) +- Provides `toAwtColor()` for AWT interop + +## GUI / Input + +- `Camera` represents viewer position and orientation +- `InputManager` processes mouse/keyboard events +- `MouseInteractionController` interface allows shapes to respond to input +- `KeyboardFocusStack` manages keyboard input focus + +# Tips for AI Agents + +1. **Creating shapes:** Extend `AbstractCoordinateShape` for simple geometry or `AbstractCompositeShape` for compounds +2. **Always use project Color:** `eu.svjatoslav.sixth.e3d.renderer.raster.Color`, never `java.awt.Color` +3. **Mutable geometry:** `Point3D`/`Point2D` are mutable — clone when storing references that shouldn't be shared +4. **Render pipeline:** Shapes must implement `transform()` and `paint()` methods +5. **Depth sorting:** Set `onScreenZ` correctly during `transform()` for proper rendering order +6. **Backface culling:** Uses signed area in screen space; `signedArea < 0` = front-facing (CCW) +7. **Polygon winding:** CCW in screen space = front face. Vertex order: top → lower-left → lower-right (as seen from + camera). See `WindingOrderDemo` in sixth-3d-demos. +8. **Testing:** Write JUnit 4 tests in `src/test/java/` with matching package structure diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/COPYING @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/TODO.org b/TODO.org new file mode 100644 index 0000000..51448e8 --- /dev/null +++ b/TODO.org @@ -0,0 +1,162 @@ +* Documentation +:PROPERTIES: +:CUSTOM_ID: documentation +:END: +** Clarify axis orientation (X, Y, Z) for AI assistants and developers +:PROPERTIES: +:CUSTOM_ID: clarify-axis-orientation +:END: +Add a coordinate system diagram to the documentation. + +** Document shading + +Make separate demo about that with shaded spheres and some light +sources. + +Make dedicated tutorial about shading algorithm with screenshot and +what are available parameters. + +** 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. + +** 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/ diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA new file mode 100755 index 0000000..304bf94 --- /dev/null +++ b/Tools/Open with IntelliJ IDEA @@ -0,0 +1,54 @@ +#!/bin/bash + +# This script launches IntelliJ IDEA with the current project +# directory. The script is designed to be run by double-clicking it in +# the GNOME Nautilus file manager. + +# First, we change the current working directory to the directory of +# the script. + +# "${0%/*}" gives us the path of the script itself, without the +# script's filename. + +# This command basically tells the system "change the current +# directory to the directory containing this script". + +cd "${0%/*}" + +# Then, we move up one directory level. +# The ".." tells the system to go to the parent directory of the current directory. +# This is done because we assume that the project directory is one level up from the script. +cd .. + +# Now, we use the 'setsid' command to start a new session and run +# IntelliJ IDEA in the background. 'setsid' is a UNIX command that +# runs a program in a new session. + +# The command 'idea .' opens IntelliJ IDEA with the current directory +# as the project directory. The '&' at the end is a UNIX command that +# runs the process in the background. The '> /dev/null' part tells +# the system to redirect all output (both stdout and stderr, denoted +# by '&') that would normally go to the terminal to go to /dev/null +# instead, which is a special file that discards all data written to +# it. + +setsid idea . &>/dev/null & + +# The 'disown' command is a shell built-in that removes a shell job +# from the shell's active list. Therefore, the shell will not send a +# SIGHUP to this particular job when the shell session is terminated. + +# '-h' option specifies that if the shell receives a SIGHUP, it also +# doesn't send a SIGHUP to the job. + +# '$!' is a shell special parameter that expands to the process ID of +# the most recent background job. +disown -h $! + + +sleep 2 + +# Finally, we use the 'exit' command to terminate the shell script. +# This command tells the system to close the terminal window after +# IntelliJ IDEA has been opened. +exit diff --git a/Tools/Update web site b/Tools/Update web site new file mode 100755 index 0000000..9daf5a4 --- /dev/null +++ b/Tools/Update web site @@ -0,0 +1,101 @@ +#!/bin/bash +cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi; + +cd .. + +# Function to export org to html using emacs in batch mode +export_org_to_html() { + local org_file=$1 + local dir=$(dirname "$org_file") + local base=$(basename "$org_file" .org) + ( + cd "$dir" || return 1 + local html_file="${base}.html" + if [ -f "$html_file" ]; then + rm -f "$html_file" + fi + echo "Exporting: $org_file → $dir/$html_file" + emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill + if [ $? -eq 0 ]; then + echo "✓ Successfully exported $org_file" + else + echo "✗ Failed to export $org_file" + return 1 + fi + ) +} + +export_org_files_to_html() { + echo "🔍 Searching for .org files in doc/ ..." + echo "=======================================" + + mapfile -t ORG_FILES < <(find doc -type f -name "*.org" | sort) + + if [ ${#ORG_FILES[@]} -eq 0 ]; then + echo "❌ No .org files found!" + return 1 + fi + + echo "Found ${#ORG_FILES[@]} .org file(s):" + printf '%s\n' "${ORG_FILES[@]}" + echo "=======================================" + + SUCCESS_COUNT=0 + FAILED_COUNT=0 + + for org_file in "${ORG_FILES[@]}"; do + export_org_to_html "$org_file" + if [ $? -eq 0 ]; then + ((SUCCESS_COUNT++)) + else + ((FAILED_COUNT++)) + fi + done + + echo "=======================================" + echo "📊 SUMMARY:" + echo " ✓ Successful: $SUCCESS_COUNT" + echo " ✗ Failed: $FAILED_COUNT" + echo " Total: $((SUCCESS_COUNT + FAILED_COUNT))" + echo "" +} + +build_visualization_graphs() { + rm -rf doc/graphs/ + mkdir -p doc/graphs/ + + javainspect -j target/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 diff --git a/doc/Developer tools.png b/doc/Developer tools.png new file mode 100644 index 0000000000000000000000000000000000000000..8dc11485f8a1d894e11f113324c11b1fcb2ad5e9 GIT binary patch literal 143754 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

%CSv^ToEWb$mVN2m7@=ArLbBf}tDyZ0HLJ2{Uu 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 KMNUMnLSTZv%wjVD literal 0 HcmV?d00001 diff --git a/doc/index.org b/doc/index.org new file mode 100644 index 0000000..b739154 --- /dev/null +++ b/doc/index.org @@ -0,0 +1,680 @@ +#+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 + +#+begin_export html + +#+end_export + + +* 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. + +* Understanding 3D engine +:PROPERTIES: +:CUSTOM_ID: defining-scene +:ID: 4b6c1355-0afe-40c6-86c3-14bf8a11a8d0 +:END: + +- To understand main render loop, see dedicated page: [[file:rendering-loop.org][Rendering loop]] + +- To understand perspective-correct texture mapping, see dedicated + page: [[file:perspective-correct-textures/][Perspective-correct textures]] + +- Study [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/][demo applications]] for practical examples. Start with [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/#minimal-example][minimal + example]]. + +** Coordinate System (X, Y, Z) +:PROPERTIES: +:CUSTOM_ID: coordinate-system +:END: + +#+BEGIN_EXPORT html + + + + + + X + right (+) / left (-) + + + Y + down (+) / up (-) + + + Z + away (+) / towards (-) + Origin + (0, 0, 0) + +#+END_EXPORT + +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. + +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. + +** Vertex +:PROPERTIES: +:CUSTOM_ID: vertex +:END: + +#+BEGIN_EXPORT html + + + + + + + + + + V + (x, y, z) + x + y + +#+END_EXPORT + +A *vertex* is a single point in 3D space, defined by three +coordinates: *x*, *y*, and *z*. Every 3D object is ultimately built +from vertices. A vertex can also carry additional data beyond +position. + +- Position: =(x, y, z)= +- Can also store: color, texture UV, normal vector +- A triangle = 3 vertices, a cube = 8 vertices +- Vertex maps to [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/geometry/Point3D.html][Point3D]] class in Sixth 3D engine. + +** Edge +:PROPERTIES: +:CUSTOM_ID: edge +:END: + +#+BEGIN_EXPORT html + + + + + + + + V₁ + V₂ + V₃ + edge + +#+END_EXPORT + +An *edge* is a straight line segment connecting two vertices. Edges +define the wireframe skeleton of a 3D model. In rendering, edges +themselves are rarely drawn — they exist implicitly as boundaries of +faces. + +- Edge = line from V₁ to V₂ +- A triangle has 3 edges +- A cube has 12 edges +- Wireframe mode renders edges visibly +- Edge is related to and can be represented by the [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.html][Line]] class in Sixth + 3D engine. + +** Face (Triangle) +:PROPERTIES: +:CUSTOM_ID: face-triangle +:END: + +#+BEGIN_EXPORT html + + + + + + + + + + V₁ + V₂ + V₃ + FACE + +#+END_EXPORT + +A *face* is a flat surface enclosed by edges. In most 3D engines, the fundamental face is a *triangle* — defined by exactly 3 vertices. Triangles are preferred because they are always planar (flat) and trivially simple to rasterize. + +- Triangle = 3 vertices + 3 edges +- Always guaranteed to be coplanar +- Quads (4 vertices) = 2 triangles +- Complex shapes = many triangles (a "mesh") +- Face maps to [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidTriangle.html][SolidTriangle]], [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]], or [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.html][TexturedTriangle]] in Sixth 3D. + +** Normal Vector +:PROPERTIES: +:CUSTOM_ID: normal-vector +:END: + +#+BEGIN_EXPORT html + + + + + + + + + N̂ + unit normal + (perpendicular + to surface) + + + Light + + L · N = brightness + +#+END_EXPORT + +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. + +- *Face normal*: one normal per triangle +- *Vertex normal*: one normal per vertex (averaged from adjacent faces for smooth shading) +- =dot(L, N)= → surface brightness +- Flat shading → face normals +- Gouraud/Phong → vertex normals + interpolation + +** Mesh +:PROPERTIES: +:CUSTOM_ID: mesh +:END: + +#+BEGIN_EXPORT html + + + + + + + + + + + + + + + + + + + triangulated + section + + +#+END_EXPORT + +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. + +- Mesh data = vertex array + index array +- Index array avoids duplicating shared vertices +- Cube: 8 vertices, 12 triangles +- Smooth sphere: hundreds–thousands of triangles +- =vertices[] + indices[]= → efficient storage +- In Sixth 3D engine: + - [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.html][AbstractCoordinateShape]]: base class for single shapes with vertices (triangles, lines). Use when creating one primitive. + - [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.html][AbstractCompositeShape]]: groups multiple shapes into one object. Use for complex models that move/rotate together. + +See the [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/#shape-gallery][Shape Gallery demo]] for a visual showcase of +all primitive shapes available in Sixth 3D, rendered in both +wireframe and solid polygon styles with dynamic lighting. + +** Winding Order & Backface Culling +:PROPERTIES: +:CUSTOM_ID: winding-order-backface-culling +:END: + +#+BEGIN_EXPORT html + + + + + + + + + + + + + + + CCW + + + + V₁ + V₂ + V₃ + FRONT FACE ✓ + + + + + CW + + + BACK FACE ✗ + (culled — not drawn) + +#+END_EXPORT + +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/SolidTriangle.html#setBackfaceCulling(boolean)][SolidTriangle.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) + +** Working with Colors +:PROPERTIES: +:CUSTOM_ID: working-with-colors +:ID: f2c9642a-a093-444f-8992-76c97ff28c16 +:END: + +Sixth 3D uses its own Color class (not java.awt.Color): + +#+BEGIN_SRC java +import eu.svjatoslav.sixth.e3d.renderer.raster.Color; + +// Using predefined colors +Color red = Color.RED; +Color green = Color.GREEN; +Color blue = Color.BLUE; + +// Create custom color (R, G, B, A) +Color custom = new Color(255, 128, 64, 200); // semi-transparent orange + +// Or use hex string +Color hex = new Color("FF8040CC"); // same orange with alpha +#+END_SRC + +* Developer tools +:PROPERTIES: +:CUSTOM_ID: developer-tools +:ID: 8c5e2a1f-9d3b-4f6a-b8e7-1c4d5f7a9b2e +:END: + +#+attr_html: :class responsive-img +#+attr_latex: :width 1000px +[[file:Developer tools.png]] + +Press *F12* anywhere in the application to open the Developer Tools panel. +This debugging interface helps you understand what the engine is doing +internally and diagnose rendering issues. + +The Developer Tools panel provides real-time insight into the rendering +pipeline with three diagnostic toggles, camera position display, frustum +culling statistics, and a live log viewer that's always recording. + +** Render frame logging (always on) +:PROPERTIES: +:CUSTOM_ID: render-frame-logging +:END: + +Render frame diagnostics are always logged to a circular buffer. When you +open the Developer Tools panel, you can see the complete rendering history. + +Log entries include: +- Abort conditions (bufferStrategy or renderingContext not available) +- Blit exceptions +- Buffer contents lost (triggers reinitialization) +- Render frame exceptions + +Use this for: +- Diagnosing buffer strategy issues (screen tearing, blank frames) +- Debugging rendering failures + +** Show polygon borders +:PROPERTIES: +:CUSTOM_ID: show-polygon-borders +:END: + +Draws yellow outlines around all textured polygons to visualize: +- Triangle tessellation patterns +- Perspective-correct texture slicing +- Polygon coverage and overlap + +This is particularly useful when debugging: +- Texture mapping issues +- Perspective distortion problems +- Mesh density and triangulation quality +- Z-fighting between overlapping polygons + +The yellow borders are rendered on top of the final image, making it +easy to see the underlying geometric structure of textured surfaces. + +** 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 Culled %: 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. + +** API access +:PROPERTIES: +:CUSTOM_ID: api-access +:END: + +You can access and control developer tools programmatically: + +#+BEGIN_SRC java +import eu.svjatoslav.sixth.e3d.gui.ViewPanel; +import eu.svjatoslav.sixth.e3d.gui.DeveloperTools; + +ViewPanel viewPanel = ...; // get your view panel +DeveloperTools tools = viewPanel.getDeveloperTools(); + +// Enable diagnostics programmatically +tools.showPolygonBorders = true; +tools.renderAlternateSegments = false; +tools.showSegmentBoundaries = true; +#+END_SRC + +This allows you to: +- Enable debugging based on command-line flags +- Toggle features during automated testing +- Create custom debug overlays or controls +- Integrate with external logging frameworks + +** Technical details +:PROPERTIES: +:CUSTOM_ID: technical-details +:END: + +The Developer Tools panel is implemented as a =JFrame= that: +- Centers on the parent =ViewPanel= window +- Runs on the Event Dispatch Thread (EDT) +- Does not block the render loop +- Automatically closes when parent window closes +- Updates statistics every 200ms while open + +Log entries are stored in a circular buffer (=DebugLogBuffer=) with +configurable capacity (default: 10,000 entries). When full, oldest +entries are discarded. + +Each =ViewPanel= has its own independent =DeveloperTools= instance, +so multiple views can have different debug configurations simultaneously. + +* 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 + +** Understanding the Sixth 3D source code +:PROPERTIES: +:CUSTOM_ID: understanding-source-code +:END: + +- Study how [[id:4b6c1355-0afe-40c6-86c3-14bf8a11a8d0][scene definition]] works. +- Understand [[file:rendering-loop.org][main rendering loop]]. +- 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) +- Study [[https://www3.svjatoslav.eu/projects/sixth-3d-demos/][demo applications]]. diff --git a/doc/perspective-correct-textures/Affine distortion.png b/doc/perspective-correct-textures/Affine distortion.png new file mode 100644 index 0000000000000000000000000000000000000000..8d3722b8592e9b229220394a85aa185cc44ee8b7 GIT binary patch literal 25825 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%0AE%+jGJHf(P^gV` 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�)u z-<<{!PS+hDJYC=H-uDiBXbXPW^UEtG=8L{phM?<~?!q9eCS-nc<$eq+uVA{&C#}^Y zv1!o#31nZ4R+I#iNqZMt>Zd$mm(BZ%wviYt`(_xR;D0~nBcRir!3KTdAldRKC}Gc$ zWU~Fh&!o&}SfOu@aVE~k*#=)p(F-||CY`ugJu@fMq0@Cv^<5M44=Pk}JI0t}`W;Ug zku(qKsZHTxV@`*yY?qf@n@y=*W?9m&bV|W1|42|Svd;1QCFJ;UvZlUOo*e`@-Rd}X z%!8#kK-DbJ62@PRFAP*KGyMe=>Q1R!_RE@0kDkC9)IOC&>=HytWp|^dU=S(W{8s() z^&6@!GeUo&ZQfxA_Mm!_>{lzY+MmX?TD$X+eP;;z4Ehz^{36$R(30aLJ{zt42l1nh zDQ5l`0Me7QW^kk>ICH>?$+F(Vo4n?gzEys@aQHe|#}KmSW5C z-H@L%0I%;0Uap6r*Dyvw={otn!XRU8P^qNNzR3g>E3V+ps34R@sH9^H-PoFCS?4u4pPxypW8K4z>C1t&=m_dS#Ya0~AL=3!zI+=iv39H`D2Y8;%eL#K kt5%f92KB*sth^&C5dCz`SrM9m3Id}j`=3mu6eRHf0Ok`k^#A|> literal 0 HcmV?d00001 diff --git a/doc/perspective-correct-textures/index.org b/doc/perspective-correct-textures/index.org new file mode 100644 index 0000000..99f43b8 --- /dev/null +++ b/doc/perspective-correct-textures/index.org @@ -0,0 +1,220 @@ +#+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 + +#+begin_export html + +#+end_export + +[[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 [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.html][TexturedPolygonTessellator]] class recursively splits triangles: + +#+BEGIN_SRC java +void tessellate(TexturedTriangle polygon) { + // Find the longest edge + TessellationEdge longest = findLongestEdge(polygon); + + if (longest.length < maxDistance) { + // Small enough: add to result + result.add(polygon); + } else { + // Split at midpoint + Vertex middle = longest.getMiddlePoint(); + // Recurse on two sub-triangles + tessellate(subTriangle1); + tessellate(subTriangle2); + } +} +#+END_SRC + +#+BEGIN_EXPORT html + + + + + + + + + + 1. Original + + + + + A + B + C + + + + longest edge + + + + + + 2. Split + + + + + + + + + + + + + M + midpoint + + + + + + + + + 3. Recurse + + + + + + + + + + + + + + + + + + + + + + + Each split halves the longest edge at its midpoint. + Recursion stops when all edges < maxDistance. + + + + midpoint (3D + UV averaged) + +#+END_EXPORT + +The midpoint is computed by averaging both 3D coordinates *and* texture +coordinates. + + +* Visualizing the Tessellation +:PROPERTIES: +:CUSTOM_ID: visualizing-tessellation +:END: + +Press *F12* to open 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/TexturedPolygonTessellator.html][TexturedPolygonTessellator]] | Triangle tessellation for perspective correction | +| [[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 | diff --git a/doc/rendering-loop.org b/doc/rendering-loop.org new file mode 100644 index 0000000..f0d0d18 --- /dev/null +++ b/doc/rendering-loop.org @@ -0,0 +1,259 @@ +#+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 + +#+begin_export html + +#+end_export + +[[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. + +** 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 thread sleeps + between frames to maintain the target rate. If rendering takes + longer than the frame interval, the engine catches up naturally + without sleeping. + +- *Unlimited mode*: Set =setFrameRate(0)= or negative. No sleeping — + renders as fast as possible. Useful for benchmarking. + +** Frame listeners +:PROPERTIES: +:CUSTOM_ID: frame-listeners +:END: + +Before each frame, the engine notifies all registered [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/FrameListener.html][FrameListener]]s: + +#+BEGIN_SRC java +viewPanel.addFrameListener((panel, deltaMs) -> { + // Update animations, physics, game logic + shape.rotate(0.01); + return true; // true = force repaint +}); +#+END_SRC + +Frame listeners can trigger repaints by returning =true=. Built-in listeners include: +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/Camera.html][Camera]] — handles keyboard/mouse navigation +- [[https://www3.svjatoslav.eu/projects/sixth-3d/apidocs/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.html][InputManager]] — processes input events + +* Rendering phases +:PROPERTIES: +:CUSTOM_ID: rendering-phases +:END: + +Each frame goes through 6 phases. Open the Developer Tools panel (F12) +to see these phases logged in real-time: + +** Phase 1: Clear canvas +:PROPERTIES: +:CUSTOM_ID: phase-1-clear-canvas +:END: + +The pixel buffer is filled with the background color (default: black). + +#+BEGIN_SRC java +Arrays.fill(pixels, 0, width * height, backgroundColorRgb); +#+END_SRC + +This is a simple =Arrays.fill= operation — very fast, single-threaded. + +** Phase 2: Transform shapes +:PROPERTIES: +:CUSTOM_ID: phase-2-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 + +This is single-threaded but very fast — just math, no pixel operations. + +** Phase 3: Sort shapes +:PROPERTIES: +:CUSTOM_ID: phase-3-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 + +Back-to-front sorting is essential for correct transparency and +occlusion. Shapes further from the camera are painted first. + +** Phase 4: Paint shapes (multi-threaded) +:PROPERTIES: +:CUSTOM_ID: phase-4-paint-shapes +:END: + +The screen is divided into 8 horizontal segments, each rendered by a separate thread: + +#+BEGIN_EXPORT html + + + + + + + + + + + Segment 0 (Thread 0) + Segment 1 (Thread 1) + Segment 2 (Thread 2) + Segment 3 (Thread 3) + Segment 4 (Thread 4) + Segment 5 (Thread 5) + Segment 6 (Thread 6) + Segment 7 (Thread 7) + +#+END_EXPORT + +Each thread: +- Gets a =SegmentRenderingContext= with Y-bounds (minY, maxY) +- 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. + +** Phase 5: Combine mouse results +:PROPERTIES: +:CUSTOM_ID: phase-5-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 5 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 6: Blit to screen +:PROPERTIES: +:CUSTOM_ID: phase-6-blit-to-screen +:END: + +The rendered =BufferedImage= is copied to the screen using +[[https://docs.oracle.com/javase/21/docs/api/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 + +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. + +* Smart repaint skipping +:PROPERTIES: +:CUSTOM_ID: smart-repaint-skipping +:END: + +The engine avoids unnecessary rendering: + +- =viewRepaintNeeded= flag: Set to =true= only when something changes +- Frame listeners can return =false= to skip repaint +- Resizing, component events, and explicit =repaintDuringNextViewUpdate()= + calls set the flag + +This means a static scene consumes almost zero CPU — the render thread +just spins checking the flag. + +* 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= | Screen 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. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..4ba6be5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,145 @@ + + 4.0.0 + eu.svjatoslav + sixth-3d + 1.4-SNAPSHOT + Sixth 3D + 3D engine + + + 21 + 21 + 21 + UTF-8 + UTF-8 + + + + svjatoslav.eu + https://svjatoslav.eu + + + + + junit + junit + 4.12 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 21 + 21 + true + UTF-8 + + + + + org.apache.maven.plugins + maven-source-plugin + 2.2.1 + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.4 + + + attach-javadocs + + jar + + + + + + + + foo + bar + + + + ${java.home}/bin/javadoc + + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4.3 + + UTF-8 + + + + + org.apache.maven.plugins + maven-release-plugin + 2.5.2 + + + org.apache.maven.scm + maven-scm-provider-gitexe + 1.9.4 + + + + + + + + org.apache.maven.wagon + wagon-ssh-external + 2.6 + + + + + + + + svjatoslav.eu + svjatoslav.eu + scpexe://svjatoslav.eu:10006/srv/maven + + + svjatoslav.eu + svjatoslav.eu + scpexe://svjatoslav.eu:10006/srv/maven + + + + + + svjatoslav.eu + Svjatoslav repository + https://www3.svjatoslav.eu/maven/ + + + + + scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/sixth-3d.git + scm:git:ssh://n0@svjatoslav.eu:10006/home/n0/git/sixth-3d.git + HEAD + + + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Box.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Box.java new file mode 100644 index 0000000..c420ee0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Box.java @@ -0,0 +1,216 @@ +/* + * 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. + * + *

Also known as: 3D rectangle, rectangular box, rectangular parallelepiped, + * cuboid, rhomboid, hexahedron, or rectangular prism.

+ * + *

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.

+ * + *

Example usage:

+ *
{@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
+ * }
+ * + * @see Point3D + */ +public class Box implements Cloneable { + + /** + * The first corner point of the box. + */ + public final Point3D p1; + /** + * The second corner point of the box (opposite corner from p1). + */ + public final Point3D p2; + + /** + * Creates a new box with both corner points at the origin. + */ + public Box() { + p1 = new Point3D(); + p2 = new Point3D(); + } + + /** + * Creates a new box with the specified corner points. + * + * @param p1 the first corner point + * @param p2 the second corner point (opposite corner) + */ + public Box(final Point3D p1, final Point3D p2) { + this.p1 = p1; + this.p2 = p2; + } + + + /** + * Enlarges the box by the specified border in all directions. + * + * @param border The border to enlarge the box by. + * If the border is negative, the box will be shrunk. + * @return The current box. + */ + public Box enlarge(final double border) { + + if (p1.x < p2.x) { + p1.translateX(-border); + p2.translateX(border); + } else { + p1.translateX(border); + p2.translateX(-border); + } + + if (p1.y < p2.y) { + p1.translateY(-border); + p2.translateY(border); + } else { + p1.translateY(border); + p2.translateY(-border); + } + + if (p1.z < p2.z) { + p1.translateZ(-border); + p2.translateZ(border); + } else { + p1.translateZ(border); + p2.translateZ(-border); + } + + return this; + } + + /** + * Creates a copy of this box with cloned corner points. + * + * @return a new box with the same corner coordinates + */ + @Override + public Box clone() { + return new Box(p1.clone(), p2.clone()); + } + + /** + * Returns the depth of the box (distance along the Z-axis). + * + * @return the depth (always positive) + */ + public double getDepth() { + return abs(p1.z - p2.z); + } + + /** + * Returns the height of the box (distance along the Y-axis). + * + * @return the height (always positive) + */ + public double getHeight() { + return abs(p1.y - p2.y); + } + + /** + * Returns the width of the box (distance along the X-axis). + * + * @return the width (always positive) + */ + public double getWidth() { + return abs(p1.x - p2.x); + } + + + /** + * Sets the size of the box. The box will be centered at the origin. + * Previous size and position of the box will be lost. + * + * @param size {@link Point3D} specifies box size in x, y and z axis. + */ + public void setBoxSize(final Point3D size) { + p2.clone(size).divide(2); + p1.clone(p2).negate(); + } + + /** + * Returns the minimum X coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller X value of p1 and p2 + */ + public double getMinX() { + return Math.min(p1.x, p2.x); + } + + /** + * Returns the maximum X coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger X value of p1 and p2 + */ + public double getMaxX() { + return Math.max(p1.x, p2.x); + } + + /** + * Returns the minimum Y coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller Y value of p1 and p2 + */ + public double getMinY() { + return Math.min(p1.y, p2.y); + } + + /** + * Returns the maximum Y coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger Y value of p1 and p2 + */ + public double getMaxY() { + return Math.max(p1.y, p2.y); + } + + /** + * Returns the minimum Z coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the smaller Z value of p1 and p2 + */ + public double getMinZ() { + return Math.min(p1.z, p2.z); + } + + /** + * Returns the maximum Z coordinate of this box. + * Useful for AABB intersection tests. + * + * @return the larger Z value of p1 and p2 + */ + public double getMaxZ() { + return Math.max(p1.z, p2.z); + } + + /** + * Returns the geometric center of this box. + * + * @return a new Point3D at the center of the box + */ + public Point3D getCenter() { + return new Point3D( + (p1.x + p2.x) / 2.0, + (p1.y + p2.y) / 2.0, + (p1.z + p2.z) / 2.0 + ); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/BspTree.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/BspTree.java new file mode 100644 index 0000000..b900a02 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/BspTree.java @@ -0,0 +1,230 @@ +/* + * 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. + * + *

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.

+ * + *

BSP Tree Structure:

+ *
+ *                 [Node: plane P]
+ *                /               \
+ *        [Front subtree]     [Back subtree]
+ *     (same side as P's     (opposite side
+ *        normal)             of P's normal)
+ * 
+ * + * @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 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 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. + * + *

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.

+ * + *

Algorithm:

+ *
    + *
  1. At each node, split polygons by the partitioning plane
  2. + *
  3. Recursively clip front fragments against the front subtree
  4. + *
  5. Recursively clip back fragments against the back subtree
  6. + *
  7. Combine and return all surviving fragments
  8. + *
+ * + *

Leaf nodes: If this node has no plane (leaf node), all polygons + * are considered outside and returned unchanged.

+ * + * @param polygons the polygons to clip against this BSP tree + * @return a new list containing only the portions outside this solid + */ + public List clipPolygons(final List 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 frontList = new ArrayList<>(); + final List 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 resultFront = frontList; + if (front != null) resultFront = front.clipPolygons(frontList); + + // Recursively clip back fragments against back subtree + List resultBack; + if (back != null) resultBack = back.clipPolygons(backList); + else resultBack = new ArrayList<>(); + + // Combine surviving fragments from both subtrees + final List 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 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 allPolygons() { + final List 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. + * + *

This method is the core BSP tree construction algorithm. It builds or + * extends the tree by choosing a partition plane and classifying each polygon:

+ * + *
    + *
  • Coplanar — polygons on the partition plane are stored in this node
  • + *
  • Front — polygons in the front half-space (same side as plane normal) + * go to the front child subtree
  • + *
  • Back — polygons in the back half-space (opposite to plane normal) + * go to the back child subtree
  • + *
  • Spanning — polygons crossing the plane are split into front and back + * fragments, each going to its respective subtree
  • + *
+ * + *

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.

+ * + *

Can be called multiple times to incrementally extend an existing tree, + * though the original partition planes remain unchanged.

+ * + * @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 polygons) { + if (polygons.isEmpty()) return; + + if (plane == null) plane = polygons.get(0).getPlane().clone(); + + final List frontList = new ArrayList<>(); + final List backList = new ArrayList<>(); + + for (final SolidPolygon polygon : polygons) + plane.splitPolygon(polygon, this.polygons, this.polygons, frontList, backList); + + if (!frontList.isEmpty()) { + if (front == null) front = new BspTree(); + front.addPolygons(frontList); + } + + if (!backList.isEmpty()) { + if (back == null) back = new BspTree(); + back.addPolygons(backList); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Circle.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Circle.java new file mode 100644 index 0000000..18dcbee --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Circle.java @@ -0,0 +1,30 @@ +/* + * 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() { + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Frustum.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Frustum.java new file mode 100644 index 0000000..6db0ff1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Frustum.java @@ -0,0 +1,266 @@ +/* + * 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. + * + *

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.

+ * + *

Frustum planes:

+ *
    + *
  • Left, Right, Top, Bottom - define the viewport edges
  • + *
  • Near - closest visible distance from camera
  • + *
  • Far - farthest visible distance from camera
  • + *
+ * + *

Usage:

+ *
{@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
+ * }
+ * }
+ * + *

AABB intersection algorithm:

+ *

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.

+ * + * @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). + * + *

This method should be called once per frame before rendering, after the + * camera position and orientation have been updated.

+ * + *

View space coordinate system:

+ *
    + *
  • Camera at origin (0, 0, 0)
  • + *
  • Forward = +Z axis (looking into the screen)
  • + *
  • Right = +X axis
  • + *
  • Up = -Y axis (since Y-down means smaller Y is higher visually)
  • + *
+ * + *

Plane normals point INTO the frustum (toward the visible volume). + * A point is inside if dot(normal, point) >= distance for all planes.

+ * + *

FOV calculation: 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.

+ * + * @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. + * + *

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.

+ * + *

Optimized algorithm:

+ *

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.

+ * + * @param box the axis-aligned bounding box to test (in view space coordinates) + * @return {@code true} if the box intersects or is inside the frustum, + * {@code false} if completely outside + */ + public boolean intersectsAABB(final Box box) { + // Get box min/max for each axis + final double minX = box.getMinX(); + final double maxX = box.getMaxX(); + final double minY = box.getMinY(); + final double maxY = box.getMaxY(); + final double minZ = box.getMinZ(); + final double maxZ = box.getMaxZ(); + + for (int i = 0; i < 6; i++) { + final Plane plane = planes[i]; + final Point3D n = plane.normal; + final double d = plane.distance; + + // Find the P-vertex: the corner most aligned with the plane normal + // If normal component is positive, use max; if negative, use min + final double px = (n.x > 0) ? maxX : minX; + final double py = (n.y > 0) ? maxY : minY; + final double pz = (n.z > 0) ? maxZ : minZ; + + // Test if P-vertex is outside the frustum (behind the plane) + // For inward-pointing normals: inside = dot(N,P) >= distance + // So outside = dot(N,P) < distance + if (n.x * px + n.y * py + n.z * pz < d) { + return false; // AABB entirely outside this plane + } + } + + return true; // AABB intersects or inside all planes + } + + /** + * Returns the near clipping plane distance. + * + * @return the near distance in world units + */ + public double getNearDistance() { + return nearDistance; + } + + /** + * Returns the far clipping plane distance. + * + * @return the far distance in world units + */ + public double getFarDistance() { + return farDistance; + } + + /** + * Sets the near and far clipping distances. + * + * @param near the near plane distance (objects closer are culled) + * @param far the far plane distance (objects farther are culled) + */ + public void setClipDistances(final double near, final double far) { + this.nearDistance = near; + this.farDistance = far; + } + + /** + * Returns a specific frustum plane for debugging or advanced usage. + * + * @param planeIndex one of LEFT, RIGHT, TOP, BOTTOM, NEAR, FAR + * @return the plane at the specified index + */ + public Plane getPlane(final int planeIndex) { + return planes[planeIndex]; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java new file mode 100644 index 0000000..48d526e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Plane.java @@ -0,0 +1,184 @@ +/* + * 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. + * + *

Planes are fundamental to BSP (Binary Space Partitioning) tree operations + * in CSG. They divide 3D space into two half-spaces.

+ * + * @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; + } + + /** + * Creates a plane from three non-collinear points. + * + * @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 + */ + public static Plane fromPoints(final Point3D a, final Point3D b, final Point3D c) { + final Point3D edge1 = b.withSubtracted(a); + final Point3D edge2 = c.withSubtracted(a); + + final Point3D cross = edge1.cross(edge2); + + if (cross.getVectorLength() < EPSILON) { + throw new ArithmeticException( + "Cannot create plane from collinear points: cross product is zero"); + } + + final Point3D n = cross.unit(); + + 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 coplanarFront, + final List coplanarBack, + final List front, + final List 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 frontVertices = new ArrayList<>(); + final List backVertices = new ArrayList<>(); + + for (int i = 0; i < vertexCount; i++) { + final int nextIndex = (i + 1) % vertexCount; + final PolygonType currentType = types[i]; + final PolygonType nextType = types[nextIndex]; + final Vertex currentVertex = polygon.vertices.get(i); + final Vertex nextVertex = polygon.vertices.get(nextIndex); + + // Add current vertex to the polygon on its side of the plane + if (currentType.isFront()) { + frontVertices.add(currentVertex.clone()); + } + if (currentType.isBack()) { + backVertices.add(currentVertex.clone()); + } + + // If edge crosses the plane, create intersection vertex for both polygons + if (currentType != nextType + && currentType != PolygonType.COPLANAR + && nextType != PolygonType.COPLANAR) { + // Calculate interpolation parameter t (0 = current, 1 = next) + // t represents where along the edge the plane intersection occurs + final double t = (distance - normal.dot(currentVertex.coordinate)) + / normal.dot(nextVertex.coordinate.withSubtracted(currentVertex.coordinate)); + + final Vertex intersectionVertex = currentVertex.interpolate(nextVertex, t); + frontVertices.add(intersectionVertex); + backVertices.add(intersectionVertex.clone()); + } + } + + if (frontVertices.size() >= 3) { + final SolidPolygon frontPoly = SolidPolygon.fromVertices( + frontVertices, polygon.getColor(), polygon.isShadingEnabled()); + front.add(frontPoly); + } + if (backVertices.size() >= 3) { + final SolidPolygon backPoly = SolidPolygon.fromVertices( + backVertices, polygon.getColor(), polygon.isShadingEnabled()); + back.add(backPoly); + } + break; + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point2D.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point2D.java new file mode 100755 index 0000000..78a9b79 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point2D.java @@ -0,0 +1,313 @@ +/* + * 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. + * + *

{@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.

+ * + *

All mutation methods return {@code this} for fluent chaining:

+ *
{@code
+ * Point2D p = new Point2D(10, 20)
+ *     .multiply(2.0)
+ *     .add(new Point2D(5, 5))
+ *     .negate();
+ * // p is now (-25, -45)
+ * }
+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, + * {@code divide}) mutate this point and return {@code this}
  • + *
  • {@code with}-prefixed methods ({@code withAdded}, {@code withSubtracted}, {@code withNegated}, + * {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one
  • + *
+ * + *

Warning: This class is mutable with public fields. Clone before storing + * references that should not be shared:

+ *
{@code
+ * Point2D safeCopy = original.clone();
+ * }
+ * + * @see Point3D the 3D equivalent + */ +public class Point2D implements Cloneable { + + /** X coordinate (horizontal axis). */ + public double x; + /** Y coordinate (vertical axis, positive = down in screen space). */ + public double y; + + /** + * Creates a point at the origin (0, 0). + */ + public Point2D() { + } + + /** + * Creates a point with the specified coordinates. + * + * @param x the X coordinate + * @param y the Y coordinate + */ + public Point2D(final double x, final double y) { + this.x = x; + this.y = y; + } + + /** + * Creates a point by copying coordinates from another point. + * + * @param parent the point to copy from + */ + public Point2D(final Point2D parent) { + x = parent.x; + y = parent.y; + } + + + /** + * Adds another point to this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to add + * @return this point (for chaining) + * @see #withAdded(Point2D) for the non-mutating version that returns a new point + */ + public Point2D add(final Point2D otherPoint) { + x += otherPoint.x; + y += otherPoint.y; + return this; + } + + /** + * Checks if both coordinates are zero. + * + * @return {@code true} if current point coordinates are equal to zero + */ + public boolean isZero() { + return (x == 0) && (y == 0); + } + + /** + * Creates a new point by copying this point's coordinates. + * + * @return a new point with the same coordinates + */ + @Override + public Point2D clone() { + return new Point2D(this); + } + + /** + * Copies coordinates from another point into this point. + * + * @param otherPoint the point to copy coordinates from + */ + public void clone(final Point2D otherPoint) { + x = otherPoint.x; + y = otherPoint.y; + } + + /** + * Sets this point to the midpoint between two other points. + * + * @param p1 the first point + * @param p2 the second point + * @return this point (for chaining) + */ + public Point2D setToMiddle(final Point2D p1, final Point2D p2) { + x = (p1.x + p2.x) / 2d; + y = (p1.y + p2.y) / 2d; + return this; + } + + /** + * Computes the angle on the X-Y plane between this point and another point. + * + * @param anotherPoint the other point + * @return the angle in radians + */ + public double getAngleXY(final Point2D anotherPoint) { + return Math.atan2(x - anotherPoint.x, y - anotherPoint.y); + } + + /** + * Computes the Euclidean distance from this point to another point. + * + * @param anotherPoint the point to compute distance to + * @return the distance between the two points + */ + public double getDistanceTo(final Point2D anotherPoint) { + final double xDiff = x - anotherPoint.x; + final double yDiff = y - anotherPoint.y; + + return sqrt(((xDiff * xDiff) + (yDiff * yDiff))); + } + + /** + * Computes the length of this vector (magnitude). + * + * @return the vector length + */ + public double getVectorLength() { + return sqrt(((x * x) + (y * y))); + } + + /** + * Negates this point's coordinates in place. + * This point is modified. + * + * @return this point (for chaining) + * @see #withNegated() for the non-mutating version that returns a new point + */ + public Point2D negate() { + x = -x; + y = -y; + return this; + } + + /** + * Rounds this point's coordinates to integer values. + */ + public void roundToInteger() { + x = (int) x; + y = (int) y; + } + + /** + * Subtracts another point from this point in place. + * This point is modified, the other point is not. + * + * @param otherPoint the point to subtract + * @return this point (for chaining) + * @see #withSubtracted(Point2D) for the non-mutating version that returns a new point + */ + public Point2D subtract(final Point2D otherPoint) { + x -= otherPoint.x; + y -= otherPoint.y; + return this; + } + + /** + * Multiplies both coordinates by a factor. + * This point is modified. + * + * @param factor the multiplier + * @return this point (for chaining) + * @see #withMultiplied(double) for the non-mutating version that returns a new point + */ + public Point2D multiply(final double factor) { + x *= factor; + y *= factor; + return this; + } + + /** + * Divides both coordinates by a factor. + * This point is modified. + * + * @param factor the divisor + * @return this point (for chaining) + * @see #withDivided(double) for the non-mutating version that returns a new point + */ + public Point2D divide(final double factor) { + x /= factor; + y /= factor; + return this; + } + + /** + * Converts this 2D point to a 3D point with z = 0. + * + * @return a new 3D point with the same x, y and z = 0 + */ + public Point3D to3D() { + return new Point3D(x, y, 0); + } + + /** + * Resets this point's coordinates to (0, 0). + * + * @return this point (for chaining) + */ + public Point2D zero() { + x = 0; + y = 0; + return this; + } + + @Override + public String toString() { + return "Point2D{" + + "x=" + x + + ", y=" + y + + '}'; + } + + /** + * Returns a new point that is the sum of this point and another. + * This point is not modified. + * + * @param other the point to add + * @return a new Point2D representing the sum + * @see #add(Point2D) for the mutating version + */ + public Point2D withAdded(final Point2D other) { + return new Point2D(x + other.x, y + other.y); + } + + /** + * Returns a new point that is this point minus another. + * This point is not modified. + * + * @param other the point to subtract + * @return a new Point2D representing the difference + * @see #subtract(Point2D) for the mutating version + */ + public Point2D withSubtracted(final Point2D other) { + return new Point2D(x - other.x, y - other.y); + } + + /** + * Returns a new point with negated coordinates. + * This point is not modified. + * + * @return a new Point2D with negated coordinates + * @see #negate() for the mutating version + */ + public Point2D withNegated() { + return new Point2D(-x, -y); + } + + /** + * Returns a new point with coordinates multiplied by a factor. + * This point is not modified. + * + * @param factor the multiplier + * @return a new Point2D with multiplied coordinates + * @see #multiply(double) for the mutating version + */ + public Point2D withMultiplied(final double factor) { + return new Point2D(x * factor, y * factor); + } + + /** + * Returns a new point with coordinates divided by a factor. + * This point is not modified. + * + * @param factor the divisor + * @return a new Point2D with divided coordinates + * @see #divide(double) for the mutating version + */ + public Point2D withDivided(final double factor) { + return new Point2D(x / factor, y / factor); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point3D.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point3D.java new file mode 100755 index 0000000..1f51c1c --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Point3D.java @@ -0,0 +1,586 @@ +/* + * 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. + * + *

{@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.

+ * + *

All mutation methods return {@code this} for fluent chaining:

+ *
{@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)
+ * }
+ * + *

Common operations:

+ *
{@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
+ * }
+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code add}, {@code subtract}, {@code negate}, {@code multiply}, + * {@code divide}) mutate this point and return {@code this}
  • + *
  • {@code with}-prefixed methods ({@code withAdded}, {@code withSubtracted}, {@code withNegated}, + * {@code withMultiplied}, {@code withDivided}) return a new point without modifying this one
  • + *
+ * + *

Warning: This class is mutable with public fields. Clone before storing + * references that should not be shared:

+ *
{@code
+ * Point3D safeCopy = original.clone();
+ * }
+ * + * @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. + *

Provides static methods for geometric computations on triangles and other polygons.

+ * + * @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. + * + *

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.

+ * + * @param point the point to test + * @param p1 the first vertex of the triangle + * @param p2 the second vertex of the triangle + * @param p3 the third vertex of the triangle + * @return {@code true} if the point is inside the triangle + */ + public static boolean pointWithinPolygon(final Point2D point, Point2D p1, Point2D p2, Point2D p3) { + + int intersectionCount = 0; + + if (intersectsLine(point, p1, p2)) + intersectionCount++; + + if (intersectsLine(point, p2, p3)) + intersectionCount++; + + if (intersectsLine(point, p3, p1)) + intersectionCount++; + + return intersectionCount == 1; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/PolygonType.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/PolygonType.java new file mode 100644 index 0000000..680c723 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/PolygonType.java @@ -0,0 +1,56 @@ +/* + * 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Rectangle.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Rectangle.java new file mode 100644 index 0000000..95c3f92 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Rectangle.java @@ -0,0 +1,83 @@ +/* + * 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. + * + *

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.

+ * + * @see Point2D + * @see Box the 3D equivalent + */ +public class Rectangle { + + /** + * The corner points of the rectangle (opposite corners). + */ + public Point2D p1, p2; + + /** + * Creates a square rectangle centered at the origin with the specified size. + * + * @param size the width and height of the square + */ + public Rectangle(final double size) { + p2 = new Point2D(size / 2, size / 2); + p1 = p2.clone().negate(); + } + + /** + * Creates a rectangle with the specified corner points. + * + * @param p1 the first corner point + * @param p2 the second corner point (opposite corner) + */ + public Rectangle(final Point2D p1, final Point2D p2) { + this.p1 = p1; + this.p2 = p2; + } + + /** + * Returns the height of the rectangle (distance along the Y-axis). + * + * @return the height (always positive) + */ + public double getHeight() { + return abs(p1.y - p2.y); + } + + /** + * Returns the leftmost X coordinate of the rectangle. + * + * @return the minimum X value + */ + public double getLowerX() { + return min(p1.x, p2.x); + } + + /** + * Returns the topmost Y coordinate of the rectangle. + * + * @return the minimum Y value + */ + public double getLowerY() { + return min(p1.y, p2.y); + } + + /** + * Returns the width of the rectangle (distance along the X-axis). + * + * @return the width (always positive) + */ + public double getWidth() { + return abs(p1.x - p2.x); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/package-info.java new file mode 100644 index 0000000..e00e5fa --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/package-info.java @@ -0,0 +1,7 @@ +/** + * 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/Camera.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/Camera.java new file mode 100644 index 0000000..d453700 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/Camera.java @@ -0,0 +1,234 @@ +/* + * 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. + * + *

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.

+ * + *

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}).

+ * + *

Programmatic camera control:

+ *
{@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);
+ * }
+ * + * @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. + * + *

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.

+ * + *

Example:

+ *
{@code
+     * Camera camera = viewPanel.getCamera();
+     * camera.getTransform().setTranslation(new Point3D(100, -50, -200));
+     * camera.lookAt(new Point3D(0, 0, 0));  // Point camera at origin
+     * }
+ * + * @param target the world-space point to look at + */ + public void lookAt(final Point3D target) { + final Point3D pos = transform.getTranslation(); + final double dx = target.x - pos.x; + final double dy = target.y - pos.y; + final double dz = target.z - pos.z; + + final double angleXZ = -Math.atan2(dx, dz); + final double horizontalDist = Math.sqrt(dx * dx + dz * dz); + final double angleYZ = -Math.atan2(dy, horizontalDist); + + transform.getRotation().set(Quaternion.fromAngles(angleXZ, angleYZ)); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/CullingStatistics.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/CullingStatistics.java new file mode 100644 index 0000000..551cdad --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/CullingStatistics.java @@ -0,0 +1,58 @@ +/* + * 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. + * + *

Updated each frame during the rendering pipeline:

+ *
    + *
  • {@link #totalComposites} - incremented before each composite's frustum test
  • + *
  • {@link #culledComposites} - incremented when a composite fails the frustum test
  • + *
+ * + *

Displayed in the {@link DeveloperToolsPanel} to help developers understand + * culling efficiency and optimize scene graphs.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/DebugLogBuffer.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DebugLogBuffer.java new file mode 100644 index 0000000..2d56100 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DebugLogBuffer.java @@ -0,0 +1,99 @@ +/* + * 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. + * + *

Captures log messages to a fixed-size circular buffer for display + * in the {@link DeveloperToolsPanel}.

+ * + *

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.

+ * + * @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 getEntries() { + final List entries = new ArrayList<>(count); + + if (count < capacity) { + for (int i = 0; i < count; i++) { + entries.add(buffer[i]); + } + } else { + for (int i = 0; i < capacity; i++) { + final int index = (head + i) % capacity; + entries.add(buffer[index]); + } + } + + return entries; + } + + /** + * Clears all buffered log entries. + */ + public synchronized void clear() { + head = 0; + count = 0; + } + + /** + * Returns the current number of log entries in the buffer. + * + * @return the number of entries + */ + public synchronized int size() { + return count; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperTools.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperTools.java new file mode 100644 index 0000000..5387c93 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperTools.java @@ -0,0 +1,46 @@ +/* + * 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. + * + *

Each {@link ViewPanel} has its own DeveloperTools instance, allowing + * different views to have independent debug configurations.

+ * + *

Settings can be toggled at runtime via the {@link DeveloperToolsPanel} + * (opened with F12 key).

+ * + * @see ViewPanel#getDeveloperTools() + * @see DeveloperToolsPanel + */ +public class DeveloperTools { + + /** + * If {@code true}, textured polygon borders are drawn in yellow. + * Useful for visualizing polygon slicing for perspective-correct rendering. + */ + public volatile boolean showPolygonBorders = false; + + /** + * If {@code true}, only render even-numbered horizontal segments (0, 2, 4, 6). + * Odd segments (1, 3, 5, 7) will remain black. Useful for detecting + * if threads render outside their allocated screen area (overdraw detection). + */ + public volatile boolean renderAlternateSegments = false; + + /** + * If {@code true}, draws red horizontal lines at segment boundaries. + * Useful for visualizing which thread renders which screen area. + * Each line marks the boundary between two adjacent rendering segments. + */ + public volatile boolean showSegmentBoundaries = false; + + /** + * Creates a new DeveloperTools instance with all debug features disabled. + */ + public DeveloperTools() { + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperToolsPanel.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperToolsPanel.java new file mode 100644 index 0000000..5e5535c --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/DeveloperToolsPanel.java @@ -0,0 +1,314 @@ +/* + * 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 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. + * + *

Opens as a popup window when F12 is pressed. Provides:

+ *
    + *
  • Checkboxes to toggle debug settings
  • + *
  • Camera position display with copy button
  • + *
  • A scrollable log viewer showing captured debug output
  • + *
  • A button to clear the log buffer
  • + *
  • Resizable window with native maximize support
  • + *
+ * + * @see DeveloperTools + * @see DebugLogBuffer + */ +public class DeveloperToolsPanel extends JFrame { + + private static final int UPDATE_INTERVAL_MS = 200; + + /** + * The view panel whose camera is being displayed. + */ + private final ViewPanel viewPanel; + /** + * The developer tools being controlled. + */ + private final DeveloperTools developerTools; + /** + * The log buffer being displayed. + */ + private final DebugLogBuffer debugLogBuffer; + /** + * The text area showing log messages. + */ + private final JTextArea logArea; + /** + * The label showing camera position. + */ + private final JLabel cameraLabel; + /** + * The label showing total composites count. + */ + private final JLabel totalCompositesLabel; + /** + * The label showing culled composites count. + */ + private final JLabel culledCompositesLabel; + /** + * The label showing culled percentage. + */ + private final JLabel culledPercentLabel; + /** + * 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)); + + final JPanel topPanel = new JPanel(); + topPanel.setLayout(new BoxLayout(topPanel, BoxLayout.Y_AXIS)); + topPanel.add(createSettingsPanel()); + topPanel.add(createCameraPanel()); + topPanel.add(createCullingPanel()); + 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 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(); + 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 updateLogDisplay() { + final List entries = debugLogBuffer.getEntries(); + final StringBuilder sb = new StringBuilder(); + for (final String entry : entries) { + sb.append(entry).append('\n'); + } + logArea.setText(sb.toString()); + + final JScrollBar vertical = ((JScrollPane) logArea.getParent().getParent()) + .getVerticalScrollBar(); + vertical.setValue(vertical.getMaximum()); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/FrameListener.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/FrameListener.java new file mode 100644 index 0000000..dcfe7a6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/FrameListener.java @@ -0,0 +1,52 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example - animating a shape:

+ *
{@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
+ * });
+ * }
+ * + *

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.

+ * + * @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. + * + *

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).

+ * + * @param viewPanel the view panel being rendered + * @param millisecondsSinceLastFrame time elapsed since the previous frame, + * for frame-rate-independent updates + * @return {@code true} if the view should be re-rendered this frame, + * {@code false} if this listener has no visual changes + */ + boolean onFrame(ViewPanel viewPanel, int millisecondsSinceLastFrame); +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/GuiComponent.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/GuiComponent.java new file mode 100644 index 0000000..82b979d --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/GuiComponent.java @@ -0,0 +1,184 @@ +/* + * 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. + * + *

{@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.

+ * + *

This class is the foundation for interactive widgets like the + * {@link eu.svjatoslav.sixth.e3d.gui.textEditorComponent.TextEditComponent}.

+ * + *

Usage example - creating a custom GUI component:

+ *
{@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);
+ * }
+ * + * @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); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/RenderingContext.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/RenderingContext.java new file mode 100644 index 0000000..4ca886e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/RenderingContext.java @@ -0,0 +1,374 @@ +/* + * 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. + * + *

A new {@code RenderingContext} is created whenever the view panel is resized. + * During rendering, shapes use this context to:

+ *
    + *
  • Access the raw pixel array ({@link #pixels}) for direct pixel manipulation
  • + *
  • Access the {@link Graphics2D} context ({@link #graphics}) for Java2D drawing
  • + *
  • Read screen dimensions ({@link #width}, {@link #height}) and the + * {@link #centerCoordinate} for coordinate projection
  • + *
  • Use the {@link #projectionScale} factor for perspective projection
  • + *
+ * + *

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.

+ * + * @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. + */ + public static final int NUM_RENDER_SEGMENTS = 8; + + /** + * 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. + */ + public final 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; + + /** + * 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. + * + *

Equivalent to {@code RenderingContext(width, height, 0, height)}.

+ * + * @param width the rendering area width in pixels + * @param height the rendering area height in pixels + */ + public RenderingContext(final int width, final int height) { + this(width, height, 0, height); + } + + /** + * Creates a new rendering context with Y-bounds for segment rendering. + * + *

Initializes the offscreen image buffer, extracts the raw pixel byte array, + * and configures anti-aliasing on the Graphics2D context.

+ * + * @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 + */ + public RenderingContext(final int width, final int height, + final int renderMinY, final int renderMaxY) { + this.width = width; + this.height = height; + this.renderMinY = renderMinY; + this.renderMaxY = renderMaxY; + this.centerCoordinate = new Point2D(width / 2d, height / 2d); + this.projectionScale = width / 3d; + + 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.centerCoordinate = parent.centerCoordinate; + this.projectionScale = parent.projectionScale; + 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[NUM_RENDER_SEGMENTS]; + final int segmentHeight = height / NUM_RENDER_SEGMENTS; + + for (int i = 0; i < NUM_RENDER_SEGMENTS; i++) { + final int minY = i * segmentHeight; + final int maxY = (i == NUM_RENDER_SEGMENTS - 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 NUM_RENDER_SEGMENTS-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 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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/SegmentRenderingContext.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/SegmentRenderingContext.java new file mode 100644 index 0000000..f01b2a8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/SegmentRenderingContext.java @@ -0,0 +1,80 @@ +/* + * 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. + * + *

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.

+ * + *

Mouse tracking is local to each segment and must be combined after all + * segments complete rendering.

+ * + * @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 NUM_RENDER_SEGMENTS-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 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/TextPointer.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/TextPointer.java new file mode 100755 index 0000000..4ad8b4d --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/TextPointer.java @@ -0,0 +1,123 @@ +/* + * 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. + *

+ * 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 { + + /** + * 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

    + *
  • -1 if this pointer is smaller than the argument pointer.
  • + *
  • 0 if they are equal.
  • + *
  • 1 if this pointer is bigger than the argument pointer.
  • + *
+ */ + @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. + *

+ * This pointer is considered to be between the pointers if it is bigger or equal to the start pointer + * and smaller than the end pointer. + * + * @param start The start pointer. + * @param end The end pointer. + * @return True if this pointer is between the specified pointers. + */ + public boolean isBetween(final TextPointer start, final TextPointer end) { + + if (start == null) + return false; + + if (end == null) + return false; + + // Make sure that start is smaller than end. + TextPointer smaller; + TextPointer bigger; + + if (end.compareTo(start) >= 0) { + smaller = start; + bigger = end; + } else { + smaller = end; + bigger = start; + } + + // Check if this pointer is between the specified pointers. + return (compareTo(smaller) >= 0) && (bigger.compareTo(this) > 0); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewFrame.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewFrame.java new file mode 100755 index 0000000..3aec6ff --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewFrame.java @@ -0,0 +1,225 @@ +/* + * 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. + * + *

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.

+ * + *

Quick start:

+ *
{@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();
+ * }
+ * + * @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; + + /** + * 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; + } + + @Override + public void windowActivated(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + + @Override + public void windowClosed(final WindowEvent e) { + } + + @Override + public void windowClosing(final WindowEvent e) { + } + + @Override + public void windowDeactivated(final WindowEvent e) { + } + + /** + * Repaint the view when the window is deiconified. + * + * Deiconified means that the window is restored from minimized state. + */ + @Override + public void windowDeiconified(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + + /** + * Do nothing when the window is iconified. + * + * Iconified means that the window is minimized. + * @param e the event to be processed + */ + @Override + public void windowIconified(final WindowEvent e) { + } + + @Override + public void windowOpened(final WindowEvent e) { + viewPanel.repaintDuringNextViewUpdate(); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java new file mode 100755 index 0000000..1405aca --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewPanel.java @@ -0,0 +1,687 @@ +/* + * 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.InputManager; +import eu.svjatoslav.sixth.e3d.gui.humaninput.KeyboardFocusStack; +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. + * + *

{@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.

+ * + *

Uses {@link BufferStrategy} for efficient page-flipping and tear-free rendering.

+ * + *

Quick start - creating a 3D view in a window:

+ *
{@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;
+ * });
+ * }
+ * + *

Architecture:

+ *
    + *
  • A background render thread continuously generates frames at the target FPS
  • + *
  • The engine intelligently skips rendering when no visual changes are detected
  • + *
  • {@link FrameListener}s are notified before each potential frame, enabling animations
  • + *
  • Mouse/keyboard input is managed by {@link InputManager}
  • + *
  • Keyboard focus is managed by {@link KeyboardFocusStack}
  • + *
+ * + * @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 frameListeners = ConcurrentHashMap.newKeySet(); + /** The executor service for parallel rendering. */ + private final ExecutorService renderExecutor = Executors.newFixedThreadPool(RenderingContext.NUM_RENDER_SEGMENTS); + /** 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. + * + *
{@code
+     * viewPanel.getRootShapeCollection().addShape(myShape);
+     * }
+ * + * @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); + 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(); + + if (bufferStrategy == null || renderingContext == null) { + debugLogBuffer.log("[VIEWPANEL] renderFrame ABORT: bufferStrategy=" + bufferStrategy + ", renderingContext=" + renderingContext); + return; + } + + renderFrameCount++; + + try { + // === Render ONCE to offscreen buffer === + // The offscreen bufferedImage is unaffected by BufferStrategy contentsRestored(), + // so we only need to render once, then retry the blit if needed. + clearCanvasAllSegments(); + rootShapeCollection.transformShapes(this, renderingContext); + rootShapeCollection.sortShapes(); + + // Phase 4: Paint segments in parallel + final int height = renderingContext.height; + final int segmentHeight = height / RenderingContext.NUM_RENDER_SEGMENTS; + final SegmentRenderingContext[] segmentContexts = new SegmentRenderingContext[RenderingContext.NUM_RENDER_SEGMENTS]; + final CountDownLatch latch = new CountDownLatch(RenderingContext.NUM_RENDER_SEGMENTS); + + for (int i = 0; i < RenderingContext.NUM_RENDER_SEGMENTS; i++) { + final int segmentIndex = i; + final int minY = i * segmentHeight; + final int maxY = (i == RenderingContext.NUM_RENDER_SEGMENTS - 1) ? height : (i + 1) * segmentHeight; + + segmentContexts[i] = new SegmentRenderingContext(renderingContext, minY, maxY, segmentIndex); + + // Skip odd segments when renderAlternateSegments is enabled for overdraw debugging + if (developerTools.renderAlternateSegments && (i % 2 == 1)) { + latch.countDown(); + continue; + } + + renderExecutor.submit(() -> { + try { + rootShapeCollection.paintShapes(segmentContexts[segmentIndex]); + } finally { + latch.countDown(); + } + }); + } + + // Wait for all segments to complete + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // Phase 5: Combine mouse results + combineMouseResults(segmentContexts); + + // Phase 6: Draw segment boundaries if enabled + // Draw directly to pixel array for efficiency - no Graphics2D allocation/dispose overhead + if (developerTools.showSegmentBoundaries) { + final int[] pixels = renderingContext.pixels; + final int width = renderingContext.width; + final int red = (255 << 16); // Red in RGB format: R=255, G=0, B=0 + for (int i = 1; i < RenderingContext.NUM_RENDER_SEGMENTS; i++) { + final int offset = i * segmentHeight * width; + Arrays.fill(pixels, offset, offset + width, red); + } + } + + // === 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; + } + } + + private void clearCanvasAllSegments() { + final int rgb = (backgroundColor.r << 16) | (backgroundColor.g << 8) | backgroundColor.b; + final int width = renderingContext.width; + final int height = renderingContext.height; + final int[] pixels = renderingContext.pixels; + + if (developerTools.renderAlternateSegments) { + // Clear only even segments (0, 2, 4, 6), leave odd segments with previous frame content + // This helps visualize what content would be overdrawn by the missing segment renders + final int segmentHeight = height / RenderingContext.NUM_RENDER_SEGMENTS; + for (int seg = 0; seg < RenderingContext.NUM_RENDER_SEGMENTS; seg += 2) { + final int minY = seg * segmentHeight; + final int maxY = (seg == RenderingContext.NUM_RENDER_SEGMENTS - 1) ? height : (seg + 1) * segmentHeight; + Arrays.fill(pixels, minY * width, maxY * width, rgb); + } + // Odd segments intentionally NOT cleared - retain previous frame's rendered content + } else { + Arrays.fill(pixels, 0, width * height, 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; + } + + /** + * 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. + *

+ * 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 + if ((renderingContext == null) + || (renderingContext.width != panelWidth) + || (renderingContext.height != panelHeight)) { + if (renderingContext != null) { + renderingContext.dispose(); + } + renderingContext = new RenderingContext(panelWidth, panelHeight); + 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewSpaceTracker.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewSpaceTracker.java new file mode 100644 index 0000000..b612a0f --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewSpaceTracker.java @@ -0,0 +1,129 @@ +/* + * 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. + * + *

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.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewUpdateTimerTask.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewUpdateTimerTask.java new file mode 100755 index 0000000..32810d9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/ViewUpdateTimerTask.java @@ -0,0 +1,31 @@ +/* + * 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(); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/Connexion3D.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/Connexion3D.java new file mode 100644 index 0000000..6580906 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/Connexion3D.java @@ -0,0 +1,48 @@ +/* + * 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(); + + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.java new file mode 100644 index 0000000..68cffda --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/InputManager.java @@ -0,0 +1,296 @@ +/* + * 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.geometry.Point3D; +import eu.svjatoslav.sixth.e3d.gui.Camera; +import eu.svjatoslav.sixth.e3d.gui.FrameListener; +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. + * + *

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).

+ * + * @see ViewPanel#getInputManager() + */ +public class InputManager implements + MouseMotionListener, KeyListener, MouseListener, MouseWheelListener, FrameListener { + + private final Map pressedKeysToPressedTimeMap = new HashMap<>(); + private final List detectedMouseEvents = new ArrayList<>(); + private final List 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. + * + * @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; + } + 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; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardFocusStack.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardFocusStack.java new file mode 100644 index 0000000..683dac3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardFocusStack.java @@ -0,0 +1,107 @@ +/* + * 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. + * + *

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.

+ * + *

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.

+ * + *

Focus flow example:

+ *
{@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
+ * }
+ * + * @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 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. + * + *

If the given handler is already the current focus owner, this method does nothing + * and returns {@code false}.

+ * + * @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; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardHelper.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardHelper.java new file mode 100644 index 0000000..9b9ce68 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardHelper.java @@ -0,0 +1,124 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@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;
+ * }
+ * }
+ * + * @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 nonText; + + static { + nonText = new HashSet<>(); + nonText.add(DOWN); + nonText.add(UP); + nonText.add(LEFT); + nonText.add(RIGHT); + + nonText.add(SHIFT); + nonText.add(ESC); + } + + /** + * Checks if the Alt key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Alt is pressed + */ + public static boolean isAltPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.ALT_DOWN_MASK) == modifiersEx; + } + + /** + * Checks if the Ctrl key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Ctrl is pressed + */ + public static boolean isCtrlPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.CTRL_DOWN_MASK) == modifiersEx; + } + + /** + * Checks if the Shift key is pressed in the given modifier mask. + * + * @param modifiersEx the extended modifier mask from {@link java.awt.event.KeyEvent#getModifiersEx()} + * @return {@code true} if Shift is pressed + */ + public static boolean isShiftPressed(final int modifiersEx) { + return (modifiersEx | InputEvent.SHIFT_DOWN_MASK) == modifiersEx; + } + + /** + * Determines whether the given key code represents a text-producing key + * (as opposed to navigation or modifier keys like arrows, Shift, Escape). + * + * @param keyCode the key code to check + * @return {@code true} if the key produces text input + */ + public static boolean isText(final int keyCode) { + return !nonText.contains(keyCode); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardInputHandler.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardInputHandler.java new file mode 100644 index 0000000..e80bcd9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/KeyboardInputHandler.java @@ -0,0 +1,54 @@ +/* + * 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: + *

+ * 1. Component receives focus, perhaps because user clicked on it with the mouse. + * 2. Now component will receive user key press and release events from the keyboard. + * 3. Component loses focus. Perhaps user chose another component to interact with. + */ +public interface KeyboardInputHandler { + + /** + * Called when the component loses keyboard focus. + * + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean focusLost(ViewPanel viewPanel); + + /** + * Called when the component receives keyboard focus. + * + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean focusReceived(ViewPanel viewPanel); + + /** + * Called when a key is pressed while the component has focus. + * + * @param event the key event + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean keyPressed(KeyEvent event, ViewPanel viewPanel); + + /** + * Called when a key is released while the component has focus. + * + * @param event the key event + * @param viewPanel the view panel that owns this handler + * @return {@code true} if view needs to be re-rendered + */ + boolean keyReleased(KeyEvent event, ViewPanel viewPanel); + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseEvent.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseEvent.java new file mode 100644 index 0000000..459d0d2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseEvent.java @@ -0,0 +1,46 @@ +/* + * 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; + + /** + *

+     * 0 - mouse over (no button pressed)
+     * 1 - left mouse button
+     * 2 - middle mouse button
+     * 3 - right mouse button
+     * 
+ */ + public int button; + + MouseEvent(final int x, final int y, final int button) { + this(new Point2D(x, y), button); + } + + MouseEvent(final Point2D coordinate, final int button) { + this.coordinate = coordinate; + this.button = button; + } + + @Override + public String toString() { + return "MouseEvent{" + + "coordinate=" + coordinate + + ", button=" + button + + '}'; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseInteractionController.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseInteractionController.java new file mode 100644 index 0000000..3a9dc3a --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/MouseInteractionController.java @@ -0,0 +1,34 @@ +/* + * 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 true if view update is needed as a consequence of this mouse enter. + */ + boolean mouseEntered(); + + /** + * Called when mouse leaves screen area occupied by component. + * + * @return true if view update is needed as a consequence of this mouse exit. + */ + boolean mouseExited(); + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/WorldNavigationUserInputTracker.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/WorldNavigationUserInputTracker.java new file mode 100644 index 0000000..c8feb38 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/WorldNavigationUserInputTracker.java @@ -0,0 +1,93 @@ +/* + * 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. + * + *

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:

+ *
    + *
  • Up arrow - move forward (positive Z)
  • + *
  • Down arrow - move backward (negative Z)
  • + *
  • Right arrow - move right (positive X)
  • + *
  • Left arrow - move left (negative X)
  • + *
+ * + *

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.

+ * + * @see KeyboardFocusStack the focus system that manages this handler + * @see Camera the camera/viewer that this handler moves + */ +public class WorldNavigationUserInputTracker implements KeyboardInputHandler, FrameListener { + + /** + * Creates a new world navigation input tracker. + */ + public WorldNavigationUserInputTracker() { + } + + @Override + public boolean onFrame(final ViewPanel viewPanel, + final int millisecondsSinceLastFrame) { + + final InputManager inputManager = viewPanel.getInputManager(); + + final Camera camera = viewPanel.getCamera(); + + final double actualAcceleration = (long) millisecondsSinceLastFrame + * camera.cameraAcceleration + * (1 + (camera.getMovementSpeed() / 10)); + + if (inputManager.isKeyPressed(KeyboardHelper.UP)) + camera.getMovementVector().z += actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.DOWN)) + camera.getMovementVector().z -= actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.RIGHT)) + camera.getMovementVector().x += actualAcceleration; + + if (inputManager.isKeyPressed(KeyboardHelper.LEFT)) + camera.getMovementVector().x -= actualAcceleration; + + camera.enforceSpeedLimit(); + + return false; + } + + @Override + public boolean focusLost(final ViewPanel viewPanel) { + viewPanel.removeFrameListener(this); + return false; + } + + @Override + public boolean focusReceived(final ViewPanel viewPanel) { + viewPanel.addFrameListener(this); + return false; + } + + @Override + public boolean keyPressed(final KeyEvent event, final ViewPanel viewContext) { + return false; + } + + @Override + public boolean keyReleased(final KeyEvent event, final ViewPanel viewContext) { + return false; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/package-info.java new file mode 100644 index 0000000..62256b9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/humaninput/package-info.java @@ -0,0 +1,7 @@ +/** + * 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/package-info.java new file mode 100644 index 0000000..ecb4d5f --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/package-info.java @@ -0,0 +1,24 @@ +/* + * 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. + * + *

This package provides the primary integration points for embedding 3D rendering + * into Java applications using Swing/AWT.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.gui.ViewPanel} - The main rendering surface (JPanel)
  • + *
  • {@link eu.svjatoslav.sixth.e3d.gui.ViewFrame} - A JFrame with embedded ViewPanel
  • + *
  • {@link eu.svjatoslav.sixth.e3d.gui.Camera} - Represents the viewer's position and orientation
  • + *
  • {@link eu.svjatoslav.sixth.e3d.gui.DeveloperTools} - Debugging and profiling utilities
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Character.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Character.java new file mode 100644 index 0000000..c49ecf7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Character.java @@ -0,0 +1,29 @@ +/* + * 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 != ' '; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/LookAndFeel.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/LookAndFeel.java new file mode 100644 index 0000000..5acabc6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/LookAndFeel.java @@ -0,0 +1,41 @@ +/* + * 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() { + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Page.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Page.java new file mode 100644 index 0000000..7be9770 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/Page.java @@ -0,0 +1,162 @@ +/* + * 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 rows = new ArrayList<>(); + + /** + * Creates a new empty page. + */ + public Page() { + } + + /** + * Ensures that the page has at least the specified number of lines. + * + * @param row the minimum number of lines required + */ + public void ensureMaxTextLine(final int row) { + while (rows.size() <= row) + rows.add(new TextLine()); + } + + /** + * Returns the character at the specified location. + * If the location is out of bounds, returns a space. + * + * @param row the row index + * @param column the column index + * @return the character at the specified location + */ + public char getChar(final int row, final int column) { + if (rows.size() <= row) + return ' '; + return rows.get(row).getCharForLocation(column); + } + + /** + * Returns the specified line. + * + * @param row The line number. + * @return The line. + */ + public TextLine getLine(final int row) { + ensureMaxTextLine(row); + return rows.get(row); + } + + /** + * Returns the length of the specified line. + * + * @param row The line number. + * @return The length of the line. + */ + public int getLineLength(final int row) { + if (rows.size() <= row) + return 0; + return rows.get(row).getLength(); + } + + /** + * Returns the number of lines in the page. + * + * @return The number of lines in the page. + */ + public int getLinesCount() { + pack(); + return rows.size(); + } + + /** + * Returns the text of the page. + * + * @return The text of the page. + */ + public String getText() { + pack(); + + final StringBuilder result = new StringBuilder(); + for (final TextLine textLine : rows) { + if (result.length() > 0) + result.append("\n"); + result.append(textLine.toString()); + } + return result.toString(); + } + + /** + * Inserts a character at the specified position. + * + * @param row the row index + * @param col the column index + * @param value the character to insert + */ + public void insertCharacter(final int row, final int col, final char value) { + getLine(row).insertCharacter(col, value); + } + + /** + * Inserts a line at the specified row. + * + * @param row the row index where to insert + * @param textLine the text line to insert + */ + public void insertLine(final int row, final TextLine textLine) { + rows.add(row, textLine); + } + + /** + * Removes empty lines from the end of the page. + */ + private void pack() { + int newLength = 0; + + for (int i = rows.size() - 1; i >= 0; i--) + if (!rows.get(i).isEmpty()) { + newLength = i + 1; + break; + } + + if (newLength == rows.size()) + return; + + rows = rows.subList(0, newLength); + } + + /** + * Removes the specified character from the page. + * + * @param row The line number. + * @param col The character number. + */ + public void removeCharacter(final int row, final int col) { + if (rows.size() <= row) + return; + getLine(row).removeCharacter(col); + } + + /** + * Removes the specified line from the page. + * + * @param row The line number. + */ + public void removeLine(final int row) { + if (rows.size() <= row) + return; + rows.remove(row); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextEditComponent.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextEditComponent.java new file mode 100755 index 0000000..f3194fc --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextEditComponent.java @@ -0,0 +1,915 @@ +/* + * 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. + * + *

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}.

+ * + *

Supported editing features:

+ *
    + *
  • Cursor navigation with arrow keys, Home, End, Page Up, and Page Down
  • + *
  • Text selection via Shift + arrow keys
  • + *
  • Clipboard operations: Ctrl+C (copy), Ctrl+X (cut), Ctrl+V (paste), Ctrl+A (select all)
  • + *
  • Word-level cursor movement with Ctrl+Left and Ctrl+Right
  • + *
  • Tab indentation and Shift+Tab dedentation for single lines and block selections
  • + *
  • Backspace dedentation of selected blocks (removes 4 spaces of indentation)
  • + *
  • Automatic scrolling when the cursor moves beyond the visible area
  • + *
+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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 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. + * + *

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.

+ * + * @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. + * + *

A full page repaint is scheduled to remove the visual selection highlight.

+ */ + public void clearSelection() { + selectionEnd = new TextPointer(selectionStart); + repaintPage = true; + } + + /** + * Copies the currently selected text to the system clipboard. + * + *

If no text is selected (i.e., selection start equals selection end), + * this method does nothing. Multi-line selections are joined with newline + * characters.

+ * + * @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. + * + *

This copies the selected text to the clipboard via {@link #copyToClipboard()}, + * then deletes the selection from the page and triggers a full repaint.

+ * + * @see #copyToClipboard() + * @see #deleteSelection() + */ + public void cutToClipboard() { + copyToClipboard(); + deleteSelection(); + repaintPage(); + } + + /** + * Deletes the currently selected text from the page. + * + *

After deletion, the selection is cleared and the cursor is moved to + * the position where the selection started.

+ * + * @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}. + * + *

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.

+ */ + 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. + * + *

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.

+ * + * @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. + * + *

The text is processed character by character. Special characters are + * handled as editing operations:

+ *
    + *
  • {@code DEL} -- deletes the character at the cursor
  • + *
  • {@code ENTER} -- splits the current line at the cursor
  • + *
  • {@code BACKSPACE} -- deletes the character before the cursor
  • + *
+ *

All other printable characters are inserted at the cursor position, + * advancing the cursor column by one for each character.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

This is an empty implementation of the {@link ClipboardOwner} interface; + * no action is taken when clipboard ownership is lost.

+ * + * @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. + * + *

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).

+ */ + 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. + * + *

Supported combinations:

+ *
    + *
  • Ctrl+A -- select all text
  • + *
  • Ctrl+X -- cut selected text to clipboard
  • + *
  • Ctrl+C -- copy selected text to clipboard
  • + *
  • Ctrl+V -- paste from clipboard
  • + *
  • Ctrl+Right -- skip to the beginning of the next word
  • + *
  • Ctrl+Left -- skip to the beginning of the previous word
  • + *
+ * + * @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. + * + *

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.

+ */ + 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. + * + *

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.

+ */ + 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. + * + *

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.

+ * + * @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. + * + *

Behavior depends on modifiers and selection state:

+ *
    + *
  • Shift+Tab with selection: dedents all selected lines by + * removing up to 4 leading spaces, if all lines have sufficient indentation
  • + *
  • Shift+Tab without selection: dedents the current line by + * removing 4 leading spaces and moving the cursor back
  • + *
  • Tab with selection: indents all selected lines by adding + * 4 leading spaces
  • + *
+ * + * @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. + * + *

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.

+ */ + 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. + * + *

Note: the current implementation delegates to + * {@link #repaintPage()} and repaints the entire page. This is a candidate + * for optimization.

+ * + * @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. + * + *

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.

+ */ + 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. + * + *

Scroll offsets are clamped so they never go below zero. A full page + * repaint is scheduled after scrolling.

+ * + * @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. + * + *

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.

+ * + * @param text the new text content for the editor; may contain newline + * characters to create multiple lines + */ + public void setText(final String text) { + // System.out.println("Set text:" + text); + cursorLocation = new TextPointer(0, 0); + scrolledCharacters = 0; + scrolledLines = 0; + selectionStart = new TextPointer(0, 0); + selectionEnd = new TextPointer(0, 0); + page = new Page(); + insertText(text); + repaintPage(); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLine.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLine.java new file mode 100755 index 0000000..932a5ea --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLine.java @@ -0,0 +1,410 @@ +/* + * 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. + * + *

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).

+ * + *

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.

+ * + * @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 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. + * + *

Trailing whitespace is automatically trimmed via {@code pack()}.

+ * + * @param value the list of characters to initialize this line with + */ + public TextLine(final List value) { + chars = value; + pack(); + } + + /** + * Creates a text line initialized with the given string. + * + *

Each character in the string is converted to a {@link Character} object. + * Trailing whitespace is automatically trimmed.

+ * + * @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. + * + *

If the line is empty, no indentation is added. Otherwise, the specified + * number of space characters are prepended to the line.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

If {@code charactersToCut} exceeds the line length, the entire line is cleared. + * If {@code charactersToCut} is zero, no changes are made.

+ * + * @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. + * + *

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.

+ * + * @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 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. + * + *

If {@code col} is greater than or equal to the current line length, + * no changes are made.

+ * + * @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. + * + *

If the column is beyond the end of this line, a space character is returned.

+ * + * @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. + * + *

Note: the returned list is the live internal list. Modifications + * to the returned list will directly affect this line.

+ * + * @return the mutable list of characters in this line + */ + public List getChars() { + return chars; + } + + /** + * Returns the indentation level of this line, measured as the number of + * leading space characters before the first non-space character. + * + *

If the line is empty, returns {@code 0}.

+ * + * @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}). + * + *

If {@code until} exceeds the line length, only the available characters + * are included. The returned line is an independent copy.

+ * + * @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 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). + * + *

If the requested range extends beyond the line length, space characters + * are used for positions past the end of the line.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

If the column is beyond the current line length, the line is padded with + * spaces. Trailing whitespace is trimmed after insertion.

+ * + * @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. + * + *

Because trailing whitespace is trimmed, an empty line means there are + * no visible characters on this line.

+ * + * @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. + * + *

If the column is beyond the end of the line, no changes are made.

+ * + * @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. + * + *

The existing characters are cleared, and each character from the string + * is added as a new {@link Character} object. Trailing whitespace is trimmed.

+ * + * @param string the new text content for this line + */ + public void setValue(final String string) { + chars.clear(); + for (final char c : string.toCharArray()) + chars.add(new Character(c)); + + pack(); + } + + /** + * Returns the string representation of this line by concatenating + * all character values. + * + * @return the text content of this line as a {@code String} + */ + @Override + public String toString() { + final StringBuilder buffer = new StringBuilder(); + + for (final Character character : chars) + buffer.append(character.value); + + return buffer.toString(); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java new file mode 100644 index 0000000..cf1eb11 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java @@ -0,0 +1,6 @@ +/** + * 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/DiamondSquare.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/DiamondSquare.java new file mode 100644 index 0000000..1801d49 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/DiamondSquare.java @@ -0,0 +1,171 @@ +/* + * 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. + *

+ * 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. + *

+ * Grid size must be 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257). + * + * @see Diamond-square algorithm + */ +public final class DiamondSquare { + + private static final double DEFAULT_ROUGHNESS = 0.6; + + private DiamondSquare() { + } + + /** + * Generates a fractal noise map using the diamond-square algorithm. + * + * @param gridSize the size of the grid (must be 2^n + 1) + * @param min the minimum value in the output + * @param max the maximum value in the output + * @param seed random seed for reproducible results + * @return a 2D array of values in range [min, max] + * @throws IllegalArgumentException if gridSize is not 2^n + 1 + */ + public static double[][] generateMap(int gridSize, double min, double max, long seed) { + return generateMap(gridSize, min, max, DEFAULT_ROUGHNESS, seed); + } + + /** + * Generates a fractal noise map using the diamond-square algorithm with custom roughness. + * + * @param gridSize the size of the grid (must be 2^n + 1) + * @param min the minimum value in the output + * @param max the maximum value in the output + * @param roughness the roughness factor (0.0 to 1.0), higher values produce more variation + * @param seed random seed for reproducible results + * @return a 2D array of values in range [min, max] + * @throws IllegalArgumentException if gridSize is not 2^n + 1 + */ + public static double[][] generateMap(int gridSize, double min, double max, double roughness, long seed) { + if (!isValidGridSize(gridSize)) { + throw new IllegalArgumentException("Grid size must be 2^n + 1 (e.g., 65, 129, 257)"); + } + + Random random = new Random(seed); + double[][] map = new double[gridSize][gridSize]; + + map[0][0] = random.nextDouble(); + map[0][gridSize - 1] = random.nextDouble(); + map[gridSize - 1][0] = random.nextDouble(); + map[gridSize - 1][gridSize - 1] = random.nextDouble(); + + int stepSize = gridSize - 1; + double currentScale = roughness; + + while (stepSize > 1) { + int halfStep = stepSize / 2; + + for (int y = 0; y < gridSize - 1; y += stepSize) { + for (int x = 0; x < gridSize - 1; x += stepSize) { + double avg = (map[y][x] + + map[y][x + stepSize] + + map[y + stepSize][x] + + map[y + stepSize][x + stepSize]) / 4.0; + map[y + halfStep][x + halfStep] = + avg + (random.nextDouble() - 0.5) * currentScale; + } + } + + for (int y = 0; y < gridSize; y += stepSize) { + for (int x = 0; x < gridSize; x += stepSize) { + if (x + halfStep < gridSize) { + double avg = map[y][x]; + if (x - halfStep >= 0) { + avg += map[y][x - halfStep]; + } + if (x + stepSize < gridSize) { + avg += map[y][x + stepSize]; + } + if (y + halfStep < gridSize) { + avg += map[y + halfStep][x + halfStep]; + } else if (y - halfStep >= 0) { + avg += map[y - halfStep][x + halfStep]; + } + map[y][x + halfStep] = + avg / 4.0 + (random.nextDouble() - 0.5) * currentScale; + } + + if (y + halfStep < gridSize) { + double avg = map[y][x]; + if (y - halfStep >= 0) { + avg += map[y - halfStep][x]; + } + if (y + stepSize < gridSize) { + avg += map[y + stepSize][x]; + } + if (x + halfStep < gridSize) { + avg += map[y + halfStep][x + halfStep]; + } else if (x - halfStep >= 0) { + avg += map[y + halfStep][x - halfStep]; + } + map[y + halfStep][x] = + avg / 4.0 + (random.nextDouble() - 0.5) * currentScale; + } + } + } + + stepSize = halfStep; + currentScale *= roughness; + } + + normalize(map, min, max); + return map; + } + + private static void normalize(double[][] map, double min, double max) { + double actualMin = Double.MAX_VALUE; + double actualMax = Double.MIN_VALUE; + + for (double[] row : map) { + for (double value : row) { + if (value < actualMin) actualMin = value; + if (value > actualMax) actualMax = value; + } + } + + double range = actualMax - actualMin; + double targetRange = max - min; + + if (range == 0) { + for (int y = 0; y < map.length; y++) { + for (int x = 0; x < map[y].length; x++) { + map[y][x] = min; + } + } + return; + } + + for (int y = 0; y < map.length; y++) { + for (int x = 0; x < map[y].length; x++) { + map[y][x] = min + (map[y][x] - actualMin) / range * targetRange; + } + } + } + + /** + * Checks if the grid size is valid for the diamond-square algorithm. + * Valid sizes are 2^n + 1 (e.g., 3, 5, 9, 17, 33, 65, 129, 257). + * + * @param size the grid size to validate + * @return true if the size is valid + */ + public static boolean isValidGridSize(int size) { + if (size < 3) return false; + int value = size - 1; + return (value & (value - 1)) == 0; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java new file mode 100644 index 0000000..9dbc255 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/Matrix3x3.java @@ -0,0 +1,67 @@ +/* + * 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. + * + *

Matrix elements are stored in row-major order:

+ *
+ * | m00 m01 m02 |
+ * | m10 m11 m12 |
+ * | m20 m21 m22 |
+ * 
+ * + * @see Point3D + */ +public class Matrix3x3 { + + public double m00; + public double m01; + public double m02; + public double m10; + public double m11; + public double m12; + public double m20; + public double m21; + public double m22; + + /** + * Creates a zero matrix. + */ + public Matrix3x3() { + } + + /** + * Returns an identity matrix. + * + * @return a new identity matrix + */ + public static Matrix3x3 identity() { + final Matrix3x3 m = new Matrix3x3(); + m.m00 = 1; + m.m11 = 1; + m.m22 = 1; + return m; + } + + /** + * Applies this matrix transformation to a point. + * + * @param in the input point (not modified) + * @param out the output point (will be modified) + */ + public void transform(final Point3D in, final Point3D out) { + final double x = m00 * in.x + m01 * in.y + m02 * in.z; + final double y = m10 * in.x + m11 * in.y + m12 * in.z; + final double z = m20 * in.x + m21 * in.y + m22 * in.z; + out.x = x; + out.y = y; + out.z = z; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java new file mode 100644 index 0000000..b6d834e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/Quaternion.java @@ -0,0 +1,281 @@ +/* + * 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. + * + *

Quaternions provide a compact representation of rotations that avoids + * gimbal lock and enables smooth interpolation (slerp).

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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. + * + *

The rotation is composed as yaw (around Y axis) followed by + * pitch (around X axis). No roll rotation is applied.

+ * + *

For full 3-axis rotation, use {@link #fromAngles(double, double, double)}.

+ * + * @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). + * + *

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.

+ * + *

Performance note: This method uses a direct Euler-to-quaternion + * formula to avoid intermediate allocations.

+ * + * @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. + * + *

For a unit quaternion, the inverse equals the conjugate: (w, -x, -y, -z). + * This represents the opposite rotation.

+ * + * @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. + * + *

This method avoids allocation by reusing an existing Matrix3x3 instance. + * Used by Transform to avoid per-vertex allocation during rotation.

+ * + * @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. + * + *

This is the inverse of {@link #fromAngles(double, double, double)}. + * Returns angles in the Y-X-Z Euler order used by this engine.

+ * + * @return array of {yaw, pitch, roll} in radians + */ + public double[] toAngles() { + final Matrix3x3 m = toMatrix3x3(); + + final double pitch = -Math.asin(Math.max(-1, Math.min(1, m.m21))); + final double yaw = -Math.atan2(m.m20, m.m22); + final double roll = -Math.atan2(m.m01, m.m11); + + return new double[]{yaw, pitch, roll}; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Transform.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Transform.java new file mode 100755 index 0000000..f1094fe --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/Transform.java @@ -0,0 +1,244 @@ +/* + * 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. + * + *

Transformations are applied in order: rotation first, then translation.

+ * + *

Performance optimization: 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.

+ * + *

Mutability convention:

+ *
    + *
  • Imperative verbs ({@code set}, {@code setTranslation}, {@code transform}) + * mutate this transform or the input point
  • + *
  • {@code with}-prefixed methods ({@code withTransformed}) + * return a new instance without modifying the original
  • + *
+ * + *

Thread safety: 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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

Warning: 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)}.

+ * + * @return the rotation quaternion (mutable reference) + */ + public Quaternion getRotation() { + return rotation; + } + + /** + * Invalidates the cached rotation matrix. + * + *

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)}.

+ * + *

This method is automatically called by {@link #set(double, double, double, double, double, double)}.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + *

This method invalidates the cached rotation matrix.

+ * + * @param x translation X coordinate + * @param y translation Y coordinate + * @param z translation Z coordinate + * @param yaw rotation around Y axis (horizontal heading) in radians + * @param pitch rotation around X axis (vertical tilt) in radians + * @param roll rotation around Z axis (bank/tilt) in radians + * @return this transform for chaining + */ + public Transform set(final double x, final double y, final double z, + final double yaw, final double pitch, final double roll) { + translation.x = x; + translation.y = y; + translation.z = z; + rotation.set(Quaternion.fromAngles(yaw, pitch, roll)); + matrixDirty = true; + return this; + } + +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/TransformStack.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/TransformStack.java new file mode 100644 index 0000000..14eec42 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/TransformStack.java @@ -0,0 +1,88 @@ +/* + * 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. + * + *

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.

+ * + *

Example:

+ *
+ * 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
+ * 
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/Vertex.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/Vertex.java new file mode 100644 index 0000000..f1eed76 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/Vertex.java @@ -0,0 +1,184 @@ +/* + * 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. + * + *

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.

+ * + *

Coordinate spaces:

+ *
    + *
  • {@link #coordinate} - Original position in local/model space
  • + *
  • {@link #transformedCoordinate} - Position relative to viewer (camera space)
  • + *
  • {@link #onScreenCoordinate} - 2D screen position after perspective projection
  • + *
+ * + *

Example:

+ *
{@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
+ * }
+ * }
+ * + * @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. + * + *

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.

+ * + * @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); + } + + // ========== CSG support methods ========== + + /** + * Creates a deep copy of this vertex. + * Clones the coordinate, normal (if present), and texture coordinate (if present). + * The transformedCoordinate and onScreenCoordinate are not cloned (they are computed per-frame). + * + * @return a new Vertex with cloned data + */ + public Vertex clone() { + final Vertex result = new Vertex(new Point3D(coordinate), + textureCoordinate != null ? new Point2D(textureCoordinate) : null); + if (normal != null) { + result.normal = new Point3D(normal); + } + return result; + } + + /** + * Flips the orientation of this vertex by negating the normal vector. + * Called when the orientation of a polygon is flipped during CSG operations. + * If normal is null, this method does nothing. + */ + public void flip() { + if (normal != null) { + normal = normal.withNegated(); + } + } + + /** + * Creates a new vertex between this vertex and another by linearly interpolating + * all properties using parameter t. + * + *

Interpolates: position, normal (if present), and texture coordinate (if present).

+ * + * @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.lerp(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.lerp(other.normal, t); + } + return result; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/math/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/math/package-info.java new file mode 100644 index 0000000..98dfff8 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/math/package-info.java @@ -0,0 +1,9 @@ +/** + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * Math that is needed for the project. + */ + +package eu.svjatoslav.sixth.e3d.math; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/package-info.java new file mode 100644 index 0000000..fc7d743 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/package-info.java @@ -0,0 +1,7 @@ +/** + * This is root package for 3D engine. Since package name cannot start with a digit, it is named "e3d" instead, + * which stands for "Engine 3D". + */ + +package eu.svjatoslav.sixth.e3d; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/IntegerPoint.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/IntegerPoint.java new file mode 100644 index 0000000..9ea02e9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/IntegerPoint.java @@ -0,0 +1,39 @@ +/* + * 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; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/OctreeVolume.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/OctreeVolume.java new file mode 100755 index 0000000..33c5935 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/OctreeVolume.java @@ -0,0 +1,1102 @@ +/* + * 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. + * + *

The octree represents a 3D volume with three cell types:

+ *
    + *
  • UNUSED - Empty cell, not yet allocated
  • + *
  • SOLID - Contains color and illumination data
  • + *
  • CLUSTER - Contains pointers to 8 child cells (for subdivision)
  • + *
+ * + *

Cell data is stored in parallel arrays ({@code cell1} through {@code cell8}) + * for memory efficiency. Each array stores different aspects of cell data.

+ * + * @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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/package-info.java new file mode 100755 index 0000000..821faf2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/package-info.java @@ -0,0 +1,20 @@ +/** + * Octree-based voxel volume representation and rendering for the Sixth 3D engine. + * + *

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.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.OctreeVolume} - the main octree data structure + * for storing and querying voxel cells
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.IntegerPoint} - integer 3D coordinate used + * for voxel addressing
  • + *
+ * + * @see eu.svjatoslav.sixth.e3d.renderer.octree.raytracer ray tracing through octree volumes + */ + +package eu.svjatoslav.sixth.e3d.renderer.octree; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/CameraView.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/CameraView.java new file mode 100644 index 0000000..63fca47 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/CameraView.java @@ -0,0 +1,55 @@ +/* + * 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); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/LightSource.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/LightSource.java new file mode 100755 index 0000000..174b130 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/LightSource.java @@ -0,0 +1,42 @@ +/* + * 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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/Ray.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/Ray.java new file mode 100755 index 0000000..afbe4b1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/Ray.java @@ -0,0 +1,71 @@ +/* + * 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}. + * + *

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.

+ * + * @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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayHit.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayHit.java new file mode 100755 index 0000000..a1c8d41 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayHit.java @@ -0,0 +1,56 @@ +/* + * 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. + * + *

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.

+ * + * @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; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayTracer.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayTracer.java new file mode 100755 index 0000000..6408e1b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RayTracer.java @@ -0,0 +1,411 @@ +/* + * 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}. + * + *

{@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.

+ * + *

Rendering pipeline

+ *
    + *
  1. The camera's view frustum corners are obtained via {@link RaytracingCamera#getCameraView()}.
  2. + *
  3. For each pixel, a primary ray is constructed from the camera center through the + * interpolated position on the view plane.
  4. + *
  5. The ray is traced through the octree using + * {@link OctreeVolume#traceCell(int, int, int, int, int, Ray)}.
  6. + *
  7. 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.
  8. + *
  9. The final pixel color is the cell's base color modulated by the accumulated light.
  10. + *
  11. Computed lighting is cached in the octree cell data ({@code cell3}) for reuse.
  12. + *
+ * + *

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.

+ * + * @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 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 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. + * + *

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.

+ */ + @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. + * + *

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.

+ * + * @param ray the ray to trace (origin and direction must be set) + * @return the packed RGB color value (0xRRGGBB), or 0 if the ray hits nothing + */ + private int traceRay(final Ray ray) { + + final int intersectingCell = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, ray); + + if (intersectingCell != -1) { + // if lighting not computed, compute it + if (octreeVolume.cell3[intersectingCell] == -1) + // if cell is larger than 1 + if (ray.hitCellSize > 1) { + // break it up + octreeVolume.breakSolidCell(intersectingCell); + return traceRay(ray); + } else { + computedLights++; + float red = 30, green = 30, blue = 30; + + for (final LightSource l : lights) { + final double xDist = (l.location.x - ray.hitCellX); + final double yDist = (l.location.y - ray.hitCellY); + final double zDist = (l.location.z - ray.hitCellZ); + + double newRed = 0, newGreen = 0, newBlue = 0; + double tempRed, tempGreen, tempBlue; + + double distance = Math.sqrt((xDist * xDist) + + (yDist * yDist) + (zDist * zDist)); + distance = (distance / 3) + 1; + + final Ray r1 = new Ray( + new Point3D( + ray.hitCellX, + ray.hitCellY - (float) 1.5, + ray.hitCellZ), + + new Point3D((float) l.location.x - (float) ray.hitCellX, l.location.y + - (ray.hitCellY - (float) 1.5), (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt1 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r1); + + if (rt1 == -1) { + newRed = (l.color.r * l.brightness) / distance; + newGreen = (l.color.g * l.brightness) / distance; + newBlue = (l.color.b * l.brightness) / distance; + } + + final Ray r2 = new Ray( + new Point3D( + ray.hitCellX - (float) 1.5, + ray.hitCellY, ray.hitCellZ), + + new Point3D( + l.location.x - (ray.hitCellX - (float) 1.5), (float) l.location.y + - (float) ray.hitCellY, (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt2 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r2); + + if (rt2 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r3 = new Ray( + new Point3D( + ray.hitCellX, ray.hitCellY, + ray.hitCellZ - (float) 1.5), + new Point3D( + (float) l.location.x - (float) ray.hitCellX, (float) l.location.y + - (float) ray.hitCellY, l.location.z + - (ray.hitCellZ - (float) 1.5)) + ); + + final int rt3 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r3); + + if (rt3 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r4 = new Ray( + new Point3D( + ray.hitCellX, + ray.hitCellY + (float) 1.5, + ray.hitCellZ), + + new Point3D( + (float) l.location.x - (float) ray.hitCellX, l.location.y + - (ray.hitCellY + (float) 1.5), (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt4 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r4); + + if (rt4 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r5 = new Ray( + new Point3D( + ray.hitCellX + (float) 1.5, + ray.hitCellY, ray.hitCellZ), + + new Point3D( + l.location.x - (ray.hitCellX + (float) 1.5), (float) l.location.y + - (float) ray.hitCellY, (float) l.location.z + - (float) ray.hitCellZ) + ); + + final int rt5 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r5); + + if (rt5 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + + final Ray r6 = new Ray( + new Point3D( + ray.hitCellX, ray.hitCellY, + ray.hitCellZ + (float) 1.5), + + new Point3D( + + (float) l.location.x - (float) ray.hitCellX, (float) l.location.y + - (float) ray.hitCellY, l.location.z + - (ray.hitCellZ + (float) 1.5))); + + final int rt6 = octreeVolume.traceCell(0, 0, 0, + octreeVolume.masterCellSize, 0, r6); + + if (rt6 == -1) { + tempRed = (l.color.r * l.brightness) / distance; + tempGreen = (l.color.g * l.brightness) / distance; + tempBlue = (l.color.b * l.brightness) / distance; + if (tempRed > newRed) + newRed = tempRed; + if (tempGreen > newGreen) + newGreen = tempGreen; + if (tempBlue > newBlue) + newBlue = tempBlue; + } + red += newRed; + green += newGreen; + blue += newBlue; + + } + + final int cellColor = octreeVolume.cell2[intersectingCell]; + + red = (red * ((cellColor & 0xFF0000) >> 16)) / 255; + green = (green * ((cellColor & 0xFF00) >> 8)) / 255; + blue = (blue * (cellColor & 0xFF)) / 255; + + if (red > 255) + red = 255; + if (green > 255) + green = 255; + if (blue > 255) + blue = 255; + + octreeVolume.cell3[intersectingCell] = (((int) red) << 16) + + (((int) green) << 8) + ((int) blue); + + } + if (octreeVolume.cell3[intersectingCell] == 0) + return octreeVolume.cell2[intersectingCell]; + return octreeVolume.cell3[intersectingCell]; + } + + // return (200 << 16) + (200 << 8) + 255; + return 0; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RaytracingCamera.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RaytracingCamera.java new file mode 100755 index 0000000..47edea9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/RaytracingCamera.java @@ -0,0 +1,136 @@ +/* + * 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); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/package-info.java new file mode 100755 index 0000000..b82ab4f --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/octree/raytracer/package-info.java @@ -0,0 +1,21 @@ +/** + * Ray tracer for rendering voxel data stored in an octree structure. + * + *

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.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.RayTracer} - main ray tracing engine
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.RaytracingCamera} - camera configuration for ray generation
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.Ray} - represents a single ray cast through the volume
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.octree.raytracer.LightSource} - defines a light source for shading
  • + *
+ * + * @see eu.svjatoslav.sixth.e3d.renderer.octree.OctreeVolume the voxel data structure + */ + +package eu.svjatoslav.sixth.e3d.renderer.octree.raytracer; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/package-info.java new file mode 100755 index 0000000..249622e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/package-info.java @@ -0,0 +1,11 @@ +/** + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * + * Various 3D renderers utilizing different rendering approaches. + * + */ + +package eu.svjatoslav.sixth.e3d.renderer; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/Color.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/Color.java new file mode 100644 index 0000000..7b82003 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/Color.java @@ -0,0 +1,353 @@ +/* + * 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. + * + *

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.

+ * + *

Mutability: Color fields are mutable to enable reuse during rendering + * (e.g., lighting calculations). This avoids allocating new Color instances per polygon.

+ * + *

Usage examples:

+ *
{@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);
+ * }
+ * + *

Important: Always use this class instead of {@link java.awt.Color} when + * working with the Sixth 3D engine's rendering pipeline.

+ * + * @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: + *
+     *                                         RGB
+     *                                         RGBA
+     *                                         RRGGBB
+     *                                         RRGGBBAA
+     *                                         
+ */ + 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. + * + *

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.

+ * + * @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. + * + *

Supported formats:

+ *
    + *
  • {@code RGB} - 3 hex digits, fully opaque
  • + *
  • {@code RGBA} - 4 hex digits
  • + *
  • {@code RRGGBB} - 6 hex digits, fully opaque
  • + *
  • {@code RRGGBBAA} - 8 hex digits
  • + *
+ * + * @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. + * + *

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.

+ * + * @param r red component (0-255) + * @param g green component (0-255) + * @param b blue component (0-255) + * @param a alpha component (0-255) + * @return this Color for chaining + */ + public Color set(final int r, final int g, final int b, final int a) { + this.r = clamp(r); + this.g = clamp(g); + this.b = clamp(b); + this.a = clamp(a); + cachedAwtColor = null; + return this; + } + + /** + * Copies values from another color. + * + * @param other the color to copy from + * @return this Color for chaining + */ + public Color set(final Color other) { + this.r = other.r; + this.g = other.g; + this.b = other.b; + this.a = other.a; + cachedAwtColor = null; + return this; + } + + /** + * Converts this color to a {@link java.awt.Color} instance for use with + * Java AWT/Swing graphics APIs. + * + * @return the equivalent {@link java.awt.Color} + */ + public java.awt.Color toAwtColor() { + if (cachedAwtColor == null) + cachedAwtColor = new java.awt.Color(r, g, b, a); + return cachedAwtColor; + } + + /** + * Converts this color to a packed ARGB integer as used by {@link java.awt.Color#getRGB()}. + * + * @return packed ARGB integer representation + */ + public int toInt() { + return (a << 24) | (r << 16) | (g << 8) | b; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Color color = (Color) o; + + if (r != color.r) return false; + if (g != color.g) return false; + if (b != color.b) return false; + return a == color.a; + } + + @Override + public int hashCode() { + int result = r; + result = 31 * result + g; + result = 31 * result + b; + result = 31 * result + a; + return result; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/RenderAggregator.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/RenderAggregator.java new file mode 100644 index 0000000..4fbd91e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/RenderAggregator.java @@ -0,0 +1,121 @@ +/* + * 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. + * + *

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.

+ * + *

When two shapes have the same Z-depth, their unique {@link AbstractCoordinateShape#shapeId} + * is used as a tiebreaker to guarantee deterministic rendering order.

+ * + *

This class is used internally by {@link ShapeCollection} during the render pipeline. + * You typically do not need to interact with it directly.

+ * + * @see ShapeCollection#paintShapes(RenderingContext) + * @see AbstractCoordinateShape#onScreenZ + */ +public class RenderAggregator { + + /** + * Creates a new render aggregator. + */ + public RenderAggregator() { + } + + private final ArrayList 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, 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); + } + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/ShapeCollection.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/ShapeCollection.java new file mode 100755 index 0000000..524538a --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/ShapeCollection.java @@ -0,0 +1,300 @@ +/* + * 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 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. + * + *

{@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.

+ * + *

Architecture:

+ *

The collection contains a single {@link AbstractCompositeShape} as its root container. + * This root composite:

+ *
    + *
  • Stores all scene shapes in its sub-shapes registry
  • + *
  • Triangulates N-vertex polygons (quads, etc.) into triangles during rendering
  • + *
  • Provides group-based visibility management (show/hide groups)
  • + *
  • Applies camera transform (position and rotation) to all shapes
  • + *
+ * + *

Usage example:

+ *
{@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));
+ * }
+ * + *

The {@link #addShape} method is synchronized, making it safe to add shapes from + * any thread while the rendering loop is active.

+ * + * @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. + * + *

Handles:

+ *
    + *
  • N-gon triangulation (quads → triangles)
  • + *
  • Group-based visibility management
  • + *
  • Camera transform application
  • + *
  • LOD slicing for nested composites
  • + *
+ * + *

The transform is updated each frame to match the camera position and rotation.

+ */ + 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. + * + *

Grouped shapes can be shown, hidden, or removed together using + * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.

+ * + * @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). + * + *

This returns the sub-shapes from the registry, unwrapped from their {@link SubShape} + * containers. For access to group and visibility metadata, use {@link #getSubShapesRegistry()}.

+ * + * @return a collection of all shapes in the scene + */ + public Collection getShapes() { + final List 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. + * + *

This provides direct access to the registry for advanced operations + * like inspecting group assignments or visibility states.

+ * + * @return the list of sub-shapes with their metadata + */ + public List 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 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. + * + *

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.

+ * + *

Frustum culling: 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.

+ * + *

Culling statistics: Statistics are reset and total shape count computed + * at the start of each frame. Visible shapes are counted as they are queued.

+ * + * @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(); + + 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.width, 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); + } + + /** + * 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. + * + *

Used internally to force retessellate when needed. Public for advanced use cases.

+ * + * @param needsRebuild {@code true} to force cache rebuild + */ + public void setCacheNeedsRebuild(final boolean needsRebuild) { + rootComposite.setCacheNeedsRebuild(needsRebuild); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.java new file mode 100644 index 0000000..7425ca9 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightSource.java @@ -0,0 +1,136 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.java new file mode 100644 index 0000000..55625d0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/LightingManager.java @@ -0,0 +1,178 @@ +/* + * 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. + * + *

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.

+ * + *

The lighting calculation considers:

+ *
    + *
  • Distance from polygon center to each light source
  • + *
  • Angle between surface normal and light direction
  • + *
  • Color and intensity of each light source
  • + *
+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see LightSource represents a single light source + * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon + */ +public class LightingManager { + + private final List 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. + * + *

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).

+ * + * @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. + * + *

Ambient light provides base illumination that affects all surfaces + * equally, regardless of their orientation.

+ * + * @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 getLights() { + return lights; + } + + /** + * Removes a light source from the scene. + * + * @param light the light source to remove + */ + public void removeLight(final LightSource light) { + lights.remove(light); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/package-info.java new file mode 100644 index 0000000..9ea82bd --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/lighting/package-info.java @@ -0,0 +1,21 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Lighting system for flat-shaded polygon rendering. + * + *

This package implements a simple Lambertian lighting model for shading + * solid polygons based on their surface normals relative to light sources.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightingManager} - Manages lights and calculates shading
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightSource} - Represents a point light source
  • + *
+ * + * @see eu.svjatoslav.sixth.e3d.renderer.raster.lighting.LightingManager + */ + +package eu.svjatoslav.sixth.e3d.renderer.raster.lighting; \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/package-info.java new file mode 100755 index 0000000..b9700ac --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/package-info.java @@ -0,0 +1,26 @@ +/** + * Rasterization-based real-time software renderer for the Sixth 3D engine. + * + *

This package provides a complete rasterization pipeline that renders 3D scenes + * to a 2D pixel buffer using traditional approaches:

+ *
    + *
  • Wireframe rendering - lines and wireframe shapes
  • + *
  • Solid polygon rendering - filled polygons with flat shading
  • + *
  • Textured polygon rendering - polygons with texture mapping and mipmap support
  • + *
  • Depth sorting - back-to-front painter's algorithm using Z-index ordering
  • + *
+ * + *

Key classes in this package:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.ShapeCollection} - root container for all 3D shapes in a scene
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.RenderAggregator} - collects and depth-sorts shapes for rendering
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.Color} - RGBA color representation with predefined constants
  • + *
+ * + * @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; + diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.java new file mode 100644 index 0000000..0ff0602 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractCoordinateShape.java @@ -0,0 +1,238 @@ +/* + * 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. + * + *

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.

+ * + *

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.

+ * + *

Creating a custom coordinate shape:

+ *
{@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
+ *     }
+ * }
+ * }
+ * + * @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}. + * + *

Stored as a mutable list to support CSG operations that modify + * polygon vertices in place (splitting, flipping).

+ */ + public final List 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 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. + * + *

The bounding box encompasses all vertices in this shape, computed + * by finding the minimum and maximum coordinates along each axis.

+ * + *

Caching: The bounding box is cached after first computation. + * If vertices change, call {@link #invalidateBounds()} before calling + * this method to trigger recomputation.

+ * + * @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. + * + *

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.

+ * + *

Usage example:

+ *
{@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);
+     * }
+ * + * @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. + * + *

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.

+ * + * @param renderBuffer the rendering context containing the pixel buffer and graphics context + */ + public abstract void paint(RenderingContext renderBuffer); + + /** + * {@inheritDoc} + * + *

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.

+ */ + @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); + } + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractShape.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractShape.java new file mode 100644 index 0000000..7367087 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/AbstractShape.java @@ -0,0 +1,144 @@ +/* + * 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. + * + *

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.

+ * + *

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.

+ * + *

Shape hierarchy overview:

+ *
+ * AbstractShape
+ *   +-- AbstractCoordinateShape   (shapes with vertex coordinates: lines, polygons)
+ *   +-- AbstractCompositeShape    (groups of sub-shapes: boxes, grids, text canvases)
+ * 
+ * + * @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. + * + *

The bounding box is used for frustum culling to determine if the shape + * is potentially visible before expensive vertex transformations.

+ * + *

Conservative default: Returns a very large box that ensures + * the shape is always considered visible. Subclasses should override to + * provide tight bounds computed from their geometry.

+ * + *

Caching: The bounding box is cached after first computation. + * If geometry changes, call {@link #invalidateBounds()} to trigger + * recomputation on next call.

+ * + * @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()}. + * + *

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.

+ * + *

Usage example:

+ *
{@code
+     * // After modifying vertex coordinates directly:
+     * vertex.coordinate.translate(0, 10, 0);
+     * shape.invalidateBounds();
+     *
+     * // Or use translate() on AbstractCoordinateShape which handles this automatically
+     * }
+ */ + public void invalidateBounds() { + cachedBoundingBox = null; + } + + /** + * Assigns a mouse interaction controller to this shape. + * + *

Example usage:

+ *
{@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; }
+     * });
+     * }
+ * + * @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. + * + *

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.

+ * + * @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); + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/Billboard.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/Billboard.java new file mode 100644 index 0000000..8e1f44c --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/Billboard.java @@ -0,0 +1,225 @@ +/* + * 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. + * + *

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.

+ * + *

Texture mapping algorithm:

+ *
    + *
  1. Calculates screen coverage based on perspective
  2. + *
  3. Clips to viewport boundaries
  4. + *
  5. Maps texture pixels to screen pixels using proportional scaling
  6. + *
+ * + * @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. + *
    + *
  • 0 means infinitely small
  • + *
  • 1 is recommended to maintain texture sharpness
  • + *
+ */ + 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. + * + *

The billboard is rendered as a screen-aligned quad centered on the projected + * position. The size is computed based on distance and scale factor.

+ * + *

Performance optimization: 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.

+ * + * @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 screen border + int onScreenCappedXStart = onScreenUncappedXStart; + if (onScreenCappedXStart < 0) + onScreenCappedXStart = 0; + + // cap X to right screen border + int onScreenCappedXEnd = onScreenUncappedXEnd; + if (onScreenCappedXEnd > targetRenderingArea.width) + onScreenCappedXEnd = targetRenderingArea.width; + + if (onScreenCappedXStart >= onScreenCappedXEnd) + return; + + final int[] targetPixels = targetRenderingArea.pixels; + final int[] sourcePixels = textureBitmap.pixels; + final int textureWidth = textureBitmap.width; + final int textureHeight = textureBitmap.height; + final int targetWidth = targetRenderingArea.width; + + // Fixed-point (16.16) texture stepping values - eliminates per-pixel division + // Source X advances by textureWidth / onScreenUncappedWidth per screen pixel + final int sourceXStep = (textureWidth << 16) / onScreenUncappedWidth; + // Source Y advances by textureHeight / onScreenUncappedHeight per screen scanline + final int sourceYStep = (textureHeight << 16) / onScreenUncappedHeight; + + // Initialize source Y position (fixed-point) at the first capped scanline + int sourceY = ((onScreenCappedYStart - onScreenUncappedYStart) * sourceYStep); + + for (int y = onScreenCappedYStart; y < onScreenCappedYEnd; y++) { + + // Convert fixed-point Y to integer scanline base address + final int sourceYInt = sourceY >> 16; + final int scanlineBase = sourceYInt * textureWidth; + + // Initialize source X position (fixed-point) at the first capped pixel + int sourceX = ((onScreenCappedXStart - onScreenUncappedXStart) * sourceXStep); + + int targetOffset = (y * targetWidth) + onScreenCappedXStart; + + for (int x = onScreenCappedXStart; x < onScreenCappedXEnd; x++) { + + // Convert fixed-point X to integer and compute source address + final int sourceAddress = scanlineBase + (sourceX >> 16); + + // Inline alpha blending from TextureBitmap.drawPixel() + final int sourcePixel = sourcePixels[sourceAddress]; + final int srcAlpha = (sourcePixel >> 24) & 0xff; + + if (srcAlpha != 0) { + if (srcAlpha == 255) { + // Fully opaque - direct copy + targetPixels[targetOffset] = sourcePixel; + } else { + // Semi-transparent - alpha blend + final int backgroundAlpha = 255 - srcAlpha; + + final int srcR = ((sourcePixel >> 16) & 0xff) * srcAlpha; + final int srcG = ((sourcePixel >> 8) & 0xff) * srcAlpha; + final int srcB = (sourcePixel & 0xff) * srcAlpha; + + final int destPixel = targetPixels[targetOffset]; + final int destR = (destPixel >> 16) & 0xff; + final int destG = (destPixel >> 8) & 0xff; + final int destB = destPixel & 0xff; + + final int r = ((destR * backgroundAlpha) + srcR) >> 8; + final int g = ((destG * backgroundAlpha) + srcG) >> 8; + final int b = ((destB * backgroundAlpha) + srcB) >> 8; + + targetPixels[targetOffset] = (r << 16) | (g << 8) | b; + } + } + + // Advance source X using fixed-point addition (no division!) + sourceX += sourceXStep; + targetOffset++; + } + + // Advance source Y using fixed-point addition (no division!) + sourceY += sourceYStep; + } + } + + /** + * Sets the scale factor for this billboard. + * + * @param scale the scale factor (1.0 is recommended for sharpness) + */ + public void setScale(final double scale) { + this.scale = scale * SCALE_MULTIPLIER; + } + + /** + * Returns the 3D position of this billboard. + * + * @return the center position in world coordinates + */ + public Point3D getLocation() { + return vertices.get(0).coordinate; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/GlowingPoint.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/GlowingPoint.java new file mode 100644 index 0000000..9822112 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/GlowingPoint.java @@ -0,0 +1,114 @@ +/* + * 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. + * + *

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.

+ * + *

Texture sharing: Glowing points of the same color share textures + * to reduce memory usage. Textures are garbage collected via WeakHashMap when + * no longer referenced.

+ * + * @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 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. + * + *

Attempts to reuse an existing texture from another glowing point of the + * same color. If none exists, creates a new texture.

+ * + * @param color the color of the glow + * @return a texture with a circular alpha gradient + */ + private static Texture getTexture(final Color color) { + // attempt to reuse texture from existing glowing point of the same color + synchronized (glowingPoints) { + for (GlowingPoint glowingPoint : glowingPoints) + if (color.equals(glowingPoint.color)) + return glowingPoint.texture; + } + + // existing texture not found, creating new one + return createTexture(color); + } + + /** + * Creates a texture for a glowing point of the given color. + * The texture is a circle with a gradient from transparent to the given color. + */ + private static Texture createTexture(final Color color) { + final Texture texture = new Texture(TEXTURE_RESOLUTION_PIXELS, TEXTURE_RESOLUTION_PIXELS, 1); + int halfResolution = TEXTURE_RESOLUTION_PIXELS / 2; + + for (int x = 0; x < TEXTURE_RESOLUTION_PIXELS; x++) + for (int y = 0; y < TEXTURE_RESOLUTION_PIXELS; y++) { + final int distanceFromCenter = (int) sqrt(pow(halfResolution - x, 2) + pow(halfResolution - y, 2)); + + int alpha = 255 - ((270 * distanceFromCenter) / halfResolution); + if (alpha < 0) + alpha = 0; + + texture.primaryBitmap.pixels[texture.primaryBitmap.getAddress(x, y)] = + (alpha << 24) | (color.r << 16) | (color.g << 8) | color.b; + } + + return texture; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.java new file mode 100644 index 0000000..895ca2c --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/Line.java @@ -0,0 +1,428 @@ +/* + * 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. + *

+ * 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. + *

+ * 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. + *

+ * 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 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 < 0) { + d1 += (dinc * (-x1)); + x1 = 0; + } + + if (x2 >= renderBuffer.width) + x2 = renderBuffer.width - 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. + * + *

This method handles two rendering modes:

+ *
    + *
  • Thin lines: When the projected width is below threshold, draws single-pixel + * lines with alpha adjusted for sub-pixel appearance.
  • + *
  • Thick lines: Creates four edge interpolators and fills the rectangular area + * scanline by scanline with perspective-correct alpha fading at edges.
  • + *
+ * + * @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); + } + } + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineAppearance.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineAppearance.java new file mode 100644 index 0000000..ab17ec2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineAppearance.java @@ -0,0 +1,97 @@ +/* + * 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. + *

+ * 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. + * + *

Example usage:

+ *
{@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);
+ * }
+ */ +public class LineAppearance { + + private final double lineWidth; + + private Color color = new Color(100, 100, 255, 255); + + /** + * Creates a line appearance with default width (1.0) and default color (light blue). + */ + public LineAppearance() { + lineWidth = 1; + } + + /** + * Creates a line appearance with the specified width and default color (light blue). + * + * @param lineWidth the line width in world units + */ + public LineAppearance(final double lineWidth) { + this.lineWidth = lineWidth; + } + + /** + * Creates a line appearance with the specified width and color. + * + * @param lineWidth the line width in world units + * @param color the line color + */ + public LineAppearance(final double lineWidth, final Color color) { + this.lineWidth = lineWidth; + this.color = color; + } + + /** + * Creates a line between two points using this appearance's width and color. + * + * @param point1 the starting point of the line + * @param point2 the ending point of the line + * @return a new Line instance + */ + public Line getLine(final Point3D point1, final Point3D point2) { + return new Line(point1, point2, color, lineWidth); + } + + /** + * Creates a line between two points using this appearance's width and a custom color. + * + * @param point1 the starting point of the line + * @param point2 the ending point of the line + * @param color the color for this specific line (overrides the default) + * @return a new Line instance + */ + public Line getLine(final Point3D point1, final Point3D point2, + final Color color) { + return new Line(point1, point2, color, lineWidth); + } + + /** + * Returns the line width configured for this appearance. + * + * @return the line width in world units + */ + public double getLineWidth() { + return lineWidth; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java new file mode 100644 index 0000000..8b3a642 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/LineInterpolator.java @@ -0,0 +1,101 @@ +/* + * 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. + *

+ * 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. + *

+ * The comparison logic prioritizes interpolators with greater vertical coverage + * to optimize scanline ordering. + */ +public class LineInterpolator { + + private double x1, y1, d1, x2, y2, d2; + + private double d; + private int height; + private int width; + private double dinc; + + /** + * Creates a new line interpolator with uninitialized endpoints. + */ + public LineInterpolator() { + } + + /** + * Checks if the given Y coordinate falls within the vertical span of this line. + * + * @param y the Y coordinate to test + * @return {@code true} if y is between y1 and y2 (inclusive) + */ + public boolean containsY(final int y) { + + if (y1 < y2) { + if (y >= y1) + return y <= y2; + } else if (y >= y2) + return y <= y1; + + return false; + } + + /** + * Returns the depth value (d) at the current Y position. + * + * @return the interpolated depth value + */ + public double getD() { + return d; + } + + /** + * Computes the X coordinate for the given Y position. + * + * @param y the Y coordinate + * @return the interpolated X coordinate + */ + public int getX(final int y) { + if (height == 0) + return (int) (x2 + x1) / 2; + + final int distanceFromY1 = y - (int) y1; + + d = d1 + ((dinc * distanceFromY1) / height); + + return (int) x1 + ((width * distanceFromY1) / height); + } + + /** + * Sets the endpoints and depth values for this line interpolator. + * + * @param x1 the X coordinate of the first point + * @param y1 the Y coordinate of the first point + * @param d1 the depth value at the first point + * @param x2 the X coordinate of the second point + * @param y2 the Y coordinate of the second point + * @param d2 the depth value at the second point + */ + public void setPoints(final double x1, final double y1, final double d1, + final double x2, final double y2, final double d2) { + + this.x1 = x1; + this.y1 = y1; + this.d1 = d1; + + this.x2 = x2; + this.y2 = y2; + this.d2 = d2; + + height = (int) y2 - (int) y1; + width = (int) x2 - (int) x1; + + dinc = d2 - d1; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/package-info.java new file mode 100644 index 0000000..970539a --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/line/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + * + *

Lines are rendered with width that adjusts based on distance from the viewer. + * The rendering uses interpolators for smooth edges and proper alpha blending.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.Line} - The line shape
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineAppearance} - Color and width configuration
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.line.LineInterpolator} - Scanline edge interpolation
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/package-info.java new file mode 100644 index 0000000..bdfc35e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/package-info.java @@ -0,0 +1,28 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Primitive shape implementations for the rasterization pipeline. + * + *

Basic shapes are the building blocks of 3D scenes. Each can be rendered + * independently and combined to create more complex objects.

+ * + *

Subpackages:

+ *
    + *
  • {@code line} - 3D line segments with perspective-correct width
  • + *
  • {@code solidpolygon} - Solid-color triangles with flat shading
  • + *
  • {@code texturedpolygon} - Triangles with UV-mapped textures
  • + *
+ * + *

Additional basic shapes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.Billboard} - Textures that always face the camera
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.GlowingPoint} - Circular gradient billboards
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java new file mode 100644 index 0000000..b58544e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/LineInterpolator.java @@ -0,0 +1,114 @@ +/* + * 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. + * + *

{@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.

+ * + *

Subpixel precision: 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.

+ * + *

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.

+ * + * @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. + * + *

Stored as double to preserve subpixel precision during interpolation, + * eliminating rounding errors that cause T-junction gaps.

+ */ + private double height; + /** + * The horizontal span (p2.x - p1.x) in double precision, which may be negative. + * + *

Stored as double to preserve subpixel precision during interpolation.

+ */ + 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. + * + *

Uses double-precision comparison to handle subpixel vertex positions correctly.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

This method stores the endpoints directly and computes spans using double-precision + * arithmetic from the Point2D coordinates.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java new file mode 100644 index 0000000..1e5033a --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java @@ -0,0 +1,774 @@ +/* + * 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). + * + *

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.

+ * + *

Rendering:

+ *
    + *
  • Fan triangulation for N-vertex polygons (N-2 triangles)
  • + *
  • Scanline rasterization with alpha blending
  • + *
  • Backface culling and flat shading support
  • + *
  • Mouse interaction via point-in-polygon testing
  • + *
+ * + *

CSG Support:

+ *
    + *
  • Lazy-computed plane for BSP operations
  • + *
  • {@link #flip()} for inverting polygon orientation
  • + *
  • {@link #deepClone()} for creating independent copies
  • + *
+ * + *

Usage examples:

+ *
{@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);
+ * }
+ * + * @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. + * + *

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.

+ */ + private static final ThreadLocal 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 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. + * + *

Lazy-computed on first call to {@link #getPlane()}.

+ */ + private Plane plane; + /** + * Flag indicating whether the plane has been computed. + */ + private boolean planeComputed = false; + /** + * 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 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. + * + *

Parameter order (color first) avoids erasure conflict with + * {@link #SolidPolygon(List, Color)} which takes List<Point3D>.

+ * + * @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 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. + * + *

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.

+ * + * @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 vertices, final Color color) { + return fromVertices(vertices, color, false); + } + + /** + * Creates a solid polygon from existing vertices with specified shading. + * + *

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.

+ * + * @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 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 createVerticesFromPoints(final Point3D[] points) { + if (points == null || points.length < 3) { + return new ArrayList<>(); + } + final List 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 createVerticesFromPoints(final List points) { + if (points == null || points.size() < 3) { + return new ArrayList<>(); + } + final List 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 < 0) x1 = 0; + + if (x2 >= renderBuffer.width) x2 = renderBuffer.width - 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. + * + *

This static method handles:

+ *
    + *
  • Rounding vertices to integer screen coordinates
  • + *
  • Mouse hover detection via point-in-triangle test
  • + *
  • Viewport clipping
  • + *
  • Scanline rasterization with alpha blending
  • + *
+ * + * @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. + * + *

Computed from the first three vertices and cached for reuse. + * Used by BSP tree construction for spatial partitioning.

+ * + * @return the Plane containing this polygon + */ + public Plane getPlane() { + if (!planeComputed) { + plane = Plane.fromPoints( + vertices.get(0).coordinate, + vertices.get(1).coordinate, + vertices.get(2).coordinate + ); + planeComputed = true; + } + return plane; + } + + // ==================== RENDERING ==================== + + /** + * Flips the orientation of this polygon. + * + *

Reverses the vertex order and negates vertex normals. + * Also flips the cached plane if computed. Used during CSG operations + * when inverting solids.

+ */ + public void flip() { + Collections.reverse(vertices); + for (final Vertex v : vertices) { + v.flip(); + } + if (planeComputed) { + plane.flip(); + } + } + + /** + * Creates a deep clone of this polygon. + * + *

Clones all vertices and preserves the color, shading, and backface culling settings. + * Used by CSG operations to create independent copies before modification.

+ * + * @return a new SolidPolygon with cloned data and preserved settings + */ + public SolidPolygon deepClone() { + final List 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 unit normal vector of this polygon. + * + * @param result the point to store the normal vector in + */ + private void calculateNormal(final Point3D result) { + if (vertices.size() < 3) { + result.x = result.y = result.z = 0; + return; + } + + final Point3D v0 = vertices.get(0).coordinate; + final Point3D v1 = vertices.get(1).coordinate; + final Point3D v2 = vertices.get(2).coordinate; + + final double ax = v1.x - v0.x; + final double ay = v1.y - v0.y; + final double az = v1.z - v0.z; + + final double bx = v2.x - v0.x; + final double by = v2.y - v0.y; + final double bz = v2.z - v0.z; + + double nx = ay * bz - az * by; + double ny = az * bx - ax * bz; + double nz = ax * by - ay * bx; + + final double length = Math.sqrt(nx * nx + ny * ny + nz * nz); + if (length > 0.0001) { + nx /= length; + ny /= length; + nz /= length; + } + + result.x = nx; + result.y = ny; + result.z = nz; + } + + /** + * 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. + * + *

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.

+ * + * @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); + calculateNormal(cachedNormal); + renderingContext.lightingManager.computeLighting( + cachedCenter, cachedNormal, color, shadedColor); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java new file mode 100644 index 0000000..71683a5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/solidpolygon/package-info.java @@ -0,0 +1,22 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Solid-color polygon rendering with scanline rasterization. + * + *

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.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon} - Unified polygon for rendering and CSG
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.solidpolygon.LineInterpolator} - Edge interpolation for scanlines
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java new file mode 100644 index 0000000..8e51e10 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/PolygonBorderInterpolator.java @@ -0,0 +1,171 @@ +/* + * 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. + * + *

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.

+ * + * @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. + * + *

Stored as double to preserve subpixel precision during interpolation, + * eliminating rounding errors that cause T-junction gaps.

+ */ + 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. + * + *

Uses double-precision comparison to handle subpixel vertex positions correctly.

+ * + * @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. + * + *

For horizontal edges (height near zero), returns the midpoint texture X.

+ * + * @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. + * + *

For horizontal edges (height near zero), returns the midpoint texture Y.

+ * + * @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. + * + *

For horizontal edges (height near zero), returns the midpoint x value + * to avoid division by zero.

+ * + * @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. + * + *

Screen coordinates are stored directly as references. Callers should + * ensure coordinates are not modified during rendering for thread safety.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java new file mode 100644 index 0000000..7291ea0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java @@ -0,0 +1,325 @@ +/* + * 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. + * + *

This class renders triangles with UV-mapped textures. For large triangles, + * the rendering may be tessellated into smaller pieces for better perspective correction.

+ * + *

Perspective-correct texture rendering:

+ *
    + *
  • Small triangles are rendered without perspective correction
  • + *
  • Larger triangles are tessellated into smaller pieces for accurate perspective
  • + *
+ * + * @see Texture + * @see Vertex#textureCoordinate + */ +public class TexturedTriangle extends AbstractCoordinateShape { + + private static final ThreadLocal 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 < 0) + x1 = 0; + + if (x2 >= renderBuffer.width) + x2 = renderBuffer.width - 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. + * + *

This method performs:

+ *
    + *
  • Backface culling check (if enabled)
  • + *
  • Mouse interaction detection
  • + *
  • Mipmap level selection based on screen coverage
  • + *
  • Scanline rasterization with texture sampling
  • + *
+ * + * @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; + } + + /** + * 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); + }); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java new file mode 100644 index 0000000..e436db7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/basic/texturedpolygon/package-info.java @@ -0,0 +1,22 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Textured triangle rendering with perspective-correct UV mapping. + * + *

Textured triangles apply 2D textures to 3D triangles using UV coordinates. + * Large triangles may be tessellated into smaller pieces for accurate perspective correction.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle} - The textured triangle shape
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.PolygonBorderInterpolator} - Edge interpolation with UVs
  • + *
+ * + * @see eu.svjatoslav.sixth.e3d.renderer.raster.shapes.basic.texturedpolygon.TexturedTriangle + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java new file mode 100644 index 0000000..20c2901 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/ForwardOrientedTextBlock.java @@ -0,0 +1,91 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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. + * + *

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}.

+ * + * @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; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/Graph.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/Graph.java new file mode 100644 index 0000000..7cf643b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/Graph.java @@ -0,0 +1,180 @@ +/* + * 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. + * + *

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.

+ * + *

The graph uses the following default configuration:

+ *
    + *
  • X-axis range: {@code 0} to {@code 20} (world units before scaling)
  • + *
  • Y-axis range: {@code -2} to {@code 2}
  • + *
  • Grid spacing: {@code 0.5} in both horizontal and vertical directions
  • + *
  • Grid color: semi-transparent blue ({@code rgba(100, 100, 250, 100)})
  • + *
  • Plot color: semi-transparent red ({@code rgba(255, 0, 0, 100)})
  • + *
+ * + *

Usage example:

+ *
{@code
+ * // Prepare data points
+ * List 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);
+ * }
+ * + * @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. + * + *

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.

+ * + * @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 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 data) { + Point3D previousPoint = null; + for (final Point2D point : data) { + + final Point3D p3d = new Point3D(point.x, point.y, 0).multiply(scale); + + if (previousPoint != null) { + + final Line line = new Line(previousPoint, p3d, plotColor, + 0.4 * scale); + + addShape(line); + } + + previousPoint = p3d; + } + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/LightSourceMarker.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/LightSourceMarker.java new file mode 100755 index 0000000..a33c52d --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/LightSourceMarker.java @@ -0,0 +1,43 @@ +/* + * 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. + * + *

Rendered as a glowing point that provides a clear, lightweight visual + * indicator useful for debugging light placement in the scene.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see GlowingPoint + * @see AbstractCompositeShape + */ +public class LightSourceMarker extends AbstractCompositeShape { + + /** + * Creates a new light source marker at the specified location. + * + * @param location the 3D position of the marker in the scene + * @param color the color of the glowing point + */ + public LightSourceMarker(final Point3D location, final Color color) { + super(location); + addShape(new GlowingPoint(new Point3D(0, 0, 0), 15, color)); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/TexturedRectangle.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/TexturedRectangle.java new file mode 100644 index 0000000..f1efcaa --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/TexturedRectangle.java @@ -0,0 +1,180 @@ +/* + * 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. + * + *

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.

+ * + *

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.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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. + * + *

After construction, call {@link #initialize(double, double, int, int, int)} to + * set up the rectangle's dimensions, texture, and triangle geometry.

+ * + * @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. + * + *

This is a convenience constructor equivalent to calling + * {@link #TexturedRectangle(Transform, int, int, int, int, int)} with + * {@code textureWidth = width} and {@code textureHeight = height}.

+ * + * @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. + * + *

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).

+ * + * @return the texture mapped onto this rectangle + */ + public Texture getTexture() { + return texture; + } + + /** + * Initializes the rectangle geometry, texture, and the two constituent textured triangles. + * + *

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.

+ * + * @param width the width of the rectangle in world units + * @param height the height of the rectangle in world units + * @param textureWidth the width of the backing texture in pixels + * @param textureHeight the height of the backing texture in pixels + * @param maxTextureUpscale the maximum mipmap upscale factor for the texture + */ + public void initialize(final double width, final double height, + final int textureWidth, final int textureHeight, + final int maxTextureUpscale) { + + topLeft = new Point3D(-width / 2, -height / 2, 0); + topRight = new Point3D(width / 2, -height / 2, 0); + bottomRight = new Point3D(width / 2, height / 2, 0); + bottomLeft = new Point3D(-width / 2, height / 2, 0); + + texture = new Texture(textureWidth, textureHeight, maxTextureUpscale); + + textureTopRight = new Point2D(textureWidth, 0); + textureTopLeft = new Point2D(0, 0); + textureBottomRight = new Point2D(textureWidth, textureHeight); + textureBottomLeft = new Point2D(0, textureHeight); + + + + + final TexturedTriangle texturedPolygon1 = new TexturedTriangle( + new Vertex(topLeft, textureTopLeft), + new Vertex(topRight, textureTopRight), + new Vertex(bottomRight, textureBottomRight), texture); + + texturedPolygon1 + .setMouseInteractionController(mouseInteractionController); + + final TexturedTriangle texturedPolygon2 = new TexturedTriangle( + new Vertex(topLeft, textureTopLeft), + new Vertex(bottomLeft, textureBottomLeft), + new Vertex(bottomRight, textureBottomRight), texture); + + texturedPolygon2 + .setMouseInteractionController(mouseInteractionController); + + addShape(texturedPolygon1); + addShape(texturedPolygon2); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java new file mode 100644 index 0000000..10652c5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java @@ -0,0 +1,1021 @@ +/* + * 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.tessellation.TexturedPolygonTessellator; + +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. + * + *

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.

+ * + *

Usage example - creating a custom composite shape:

+ *
{@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);
+ * }
+ * + *

Level-of-detail tessellation:

+ *

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.

+ * + *

Extending this class:

+ *

Override {@link #beforeTransformHook} to customize shape appearance or behavior + * on each frame (e.g., animations, dynamic geometry updates).

+ * + * @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. + * + *

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).

+ * + *

Performance note: 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.

+ * + * @see #cachedRenderList the frame-optimized cache derived from this registry + * @see #cacheNeedsRebuild the flag controlling when the cache is rebuilt + */ + private final List 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. + *

+ * TODO: move this to TexturedTriangle. LOD must be computed per textured triangle, not per-shape. + */ + double currentTessellationFactor = 5; + + /** + * Frame-optimized cache of shapes ready for rendering, derived from {@link #subShapesRegistry}. + * + *

This list is processed during every frame in the {@link #transform} method. + * It contains:

+ *
    + *
  • Non-textured shapes (Line, SolidPolygon) - passed through directly
  • + *
  • Textured polygons - tessellated into smaller triangles based on current LOD factor
  • + *
+ * + *

Caching strategy: Regenerating this list involves texture tessellation which + * is expensive. The list is rebuilt only when {@link #cacheNeedsRebuild} is true, + * avoiding per-frame reconstruction overhead.

+ * + * @see #subShapesRegistry the source registry this cache is derived from + * @see #cacheNeedsRebuild the flag that triggers cache regeneration + */ + private List cachedRenderList = new ArrayList<>(); + + /** + * Flag indicating whether {@link #cachedRenderList} needs to be rebuilt from {@link #subShapesRegistry}. + * + *

Set to {@code true} when:

+ *
    + *
  • A shape is added via {@link #addShape}
  • + *
  • A shape is removed via {@link #removeGroup}
  • + *
  • Group visibility changes via {@link #showGroup} or {@link #hideGroup}
  • + *
  • The tessellation factor changes significantly (determined by {@link #isRetessellationNeeded})
  • + *
+ * + *

Set to {@code false} after {@link #retessellate} completes the cache rebuild.

+ * + *

This flag enables the performance optimization of avoiding per-frame list + * reconstruction - the registry is only re-processed when something actually changed.

+ * + * @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). + * + *

Root composites have different behavior for LOD-based tessellation:

+ *
    + *
  • Root position equals camera position, so distance to camera is always 0
  • + *
  • ViewSpaceTracker cannot compute meaningful tessellation factor for root
  • + *
  • Root uses fixed {@link #currentTessellationFactor} and skips LOD-based retessellation checks
  • + *
  • Root still performs N-gon triangulation when {@link #cacheNeedsRebuild} is true
  • + *
+ * + *

Set via {@link #setRootComposite(boolean)} by ShapeCollection.

+ */ + 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. + * + *

Grouped shapes can be shown, hidden, or removed together using + * {@link #showGroup}, {@link #hideGroup}, and {@link #removeGroup}.

+ * + * @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. + * + *

The bounding box is computed by aggregating the bounds of all visible + * sub-shapes, then transforming the result by this composite's own transform.

+ * + *

Caching: 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.

+ * + * @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). + * + *

This is the authoritative list of all sub-shapes including hidden ones. + * For per-frame rendering, use {@link #cachedRenderList} instead (accessed internally).

+ * + * @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 getSubShapesRegistry() { + return subShapesRegistry; + } + + /** + * Extracts all SolidPolygon instances from this composite shape. + * + *

Recursively traverses the shape hierarchy and collects all + * SolidPolygon instances. Used for CSG operations where polygons + * are needed directly without conversion.

+ * + * @return list of SolidPolygon instances from this shape hierarchy + */ + public List extractSolidPolygons() { + final List 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. + *

+ * 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 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 getGroup(final String groupIdentifier) { + final List 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. + * + *

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).

+ * + *

For normal composites, checks both cache validity and LOD-based tessellation factor changes.

+ * + * @param context the rendering context for logging + */ + private void retessellateIfNeeded(final RenderingContext context) { + + if (isRootComposite) { + if (cacheNeedsRebuild) + retessellate(context); + return; + } + + final double proposedTessellationFactor = viewSpaceTracker.proposeTessellationFactor(); + + if (isRetessellationNeeded(proposedTessellationFactor, currentTessellationFactor)) { + currentTessellationFactor = proposedTessellationFactor; + retessellate(context); + } + } + + /** + * Paint solid elements of this composite shape into given color. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @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. + * + *

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.

+ * + *

Called by {@code ShapeCollection} to configure its root composite.

+ * + * @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, forcing {@link #cachedRenderList} to be regenerated. + * + *

Used by {@code ShapeCollection} to trigger retessellate when clearing the scene + * or for other advanced use cases.

+ * + * @param needsRebuild {@code true} to force cache rebuild on next frame + */ + public void setCacheNeedsRebuild(final boolean needsRebuild) { + this.cacheNeedsRebuild = 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. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @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. + * + *

Applies recursively to nested {@code AbstractCompositeShape} sub-shapes.

+ * + * @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. + * + *

This shape's SolidPolygon children are replaced with the union result. + * Non-SolidPolygon children from both shapes are preserved and combined.

+ * + *

CSG Operation: Union combines two shapes into one, keeping all + * geometry from both. Uses BSP tree algorithms for robust boolean operations.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from both shapes → replaced with union result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • Non-SolidPolygon children from other shape → added to this shape
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged (not recursively processed)
  • + *
+ * + * @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. + * + *

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.

+ * + *

CSG Operation: Subtract removes the volume of the second shape + * from the first shape. Useful for creating holes, cavities, and cutouts.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from this shape → replaced with difference result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • All children from other shape → discarded (other is just a cutter)
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged
  • + *
+ * + * @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. + * + *

This shape's SolidPolygon children are replaced with the intersection result. + * Only the overlapping volume between the two shapes remains.

+ * + *

CSG Operation: Intersect keeps only the volume where both shapes + * overlap. Useful for creating shapes constrained by multiple boundaries.

+ * + *

Child handling:

+ *
    + *
  • SolidPolygon children from this shape → replaced with intersection result
  • + *
  • Non-SolidPolygon children from this shape → preserved
  • + *
  • All children from other shape → discarded
  • + *
  • Nested AbstractCompositeShape children → preserved unchanged
  • + *
+ * + * @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. + * + *

CSG operations modify polygons in-place via BSP tree operations. + * Cloning ensures the original polygon data is preserved.

+ * + * @param polygons the polygons to clone + * @return a new list containing deep clones of all polygons + */ + private List clonePolygons(final List polygons) { + final List 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. + * + *

Preserves all non-SolidPolygon children (Lines, nested composites, etc.).

+ * + * @param newPolygons the polygons to replace with + */ + private void replaceSolidPolygons(final List newPolygons) { + // Remove all direct SolidPolygon children from this shape + final Iterator 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. + * + *

Copies all non-SolidPolygon children (Lines, nested composites, etc.) + * from the other shape, preserving their group identifiers.

+ * + * @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 RenderingContext context) { + cacheNeedsRebuild = false; + + final List result = new ArrayList<>(); + + 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) { + tessellator.tessellate((TexturedTriangle) shape); + texturedPolygonCount++; + } else if (shape instanceof SolidPolygon polygon) { + final int vertexCount = polygon.getVertexCount(); + + if (vertexCount == 3) { + result.add(polygon); + solidPolygonCount++; + } else { + triangulateSolidPolygon(polygon, result); + triangulatedPolygonCount++; + } + } else { + result.add(shape); + otherShapeCount++; + } + } + + result.addAll(tessellator.getResult()); + + 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. + * + *

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.

+ * + *

Properties (color, shading, backface culling, mouse interaction) are + * propagated to each resulting triangle to ensure consistent behavior.

+ * + * @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 result) { + + final Color color = polygon.getColor(); + final boolean shadingEnabled = polygon.isShadingEnabled(); + final boolean backfaceCulling = polygon.isBackfaceCullingEnabled(); + final MouseInteractionController mouseController = polygon.mouseInteractionController; + + final List 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(context); + + // transform rendered subshapes + for (final AbstractShape shape : cachedRenderList) + shape.transform(transformPipe, aggregator, context); + + 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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/SubShape.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/SubShape.java new file mode 100644 index 0000000..b3bfc81 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/SubShape.java @@ -0,0 +1,128 @@ +/* + * 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. + * + *

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.

+ * + * @see AbstractCompositeShape#addShape(AbstractShape, String) + * @see AbstractCompositeShape#hideGroup(String) + * @see AbstractCompositeShape#showGroup(String) + */ +public class SubShape { + + /** + * The wrapped shape that belongs to the parent composite shape. + * This is the actual renderable geometry (line, polygon, etc.). + */ + private final AbstractShape shape; + + /** + * Whether this sub-shape should be rendered. + * Hidden shapes remain in the composite but are excluded from rendering. + */ + private boolean visible = true; + + /** + * The group identifier for batch visibility operations. + * {@code null} indicates this shape is not part of any named group. + */ + private String groupIdentifier; + + /** + * Creates a sub-shape wrapper around the given shape with default visibility (visible). + * + * @param shape the shape to wrap + */ + public SubShape(final AbstractShape shape) { + this(shape, null, true); + } + + /** + * Creates a sub-shape with all properties specified. + * + * @param shape the shape to wrap + * @param groupIdentifier the group identifier, or {@code null} for ungrouped + * @param visible whether the shape is initially visible + */ + public SubShape(final AbstractShape shape, final String groupIdentifier, final boolean visible) { + this.shape = shape; + this.groupIdentifier = groupIdentifier; + this.visible = visible; + } + + /** + * Returns {@code true} if this sub-shape has no group assigned. + * + * @return {@code true} if ungrouped + */ + public boolean isUngrouped() { + return groupIdentifier == null; + } + + /** + * Checks whether this sub-shape belongs to the specified group. + * + * @param groupIdentifier the group identifier to match against, or {@code null} to match ungrouped shapes + * @return {@code true} if this sub-shape belongs to the specified group + */ + public boolean matchesGroup(final String groupIdentifier) { + return Objects.equals(this.groupIdentifier, groupIdentifier); + } + + /** + * Returns the group identifier for this sub-shape. + * + * @return the group identifier, or {@code null} if this shape is ungrouped + */ + public String getGroupIdentifier() { + return groupIdentifier; + } + + /** + * Assigns this sub-shape to a group. + * + * @param groupIdentifier the group identifier, or {@code null} to make it ungrouped + */ + public void setGroup(final String groupIdentifier) { + this.groupIdentifier = groupIdentifier; + } + + /** + * Returns the wrapped shape. + * + * @return the underlying shape + */ + public AbstractShape getShape() { + return shape; + } + + /** + * Returns whether this sub-shape is currently visible and will be rendered. + * + * @return {@code true} if visible + */ + public boolean isVisible() { + return visible; + } + + /** + * Sets the visibility of this sub-shape. + * + * @param visible {@code true} to make the shape visible, {@code false} to hide it + */ + public void setVisible(boolean visible) { + this.visible = visible; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/package-info.java new file mode 100644 index 0000000..877c939 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/base/package-info.java @@ -0,0 +1,24 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Base class and utilities for composite shapes. + * + *

{@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.base.AbstractCompositeShape} + * is the foundation for building complex 3D objects by grouping primitives.

+ * + *

Features:

+ *
    + *
  • Position and rotation in 3D space
  • + *
  • Named groups for selective visibility
  • + *
  • Automatic sub-shape management
  • + *
  • Integration with lighting and slicing
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/package-info.java new file mode 100644 index 0000000..1cb46b5 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + * + *

Composite shapes allow building complex objects from simpler primitives. + * They support grouping, visibility toggling, and hierarchical transformations.

+ * + *

Subpackages:

+ *
    + *
  • {@code base} - Base class for all composite shapes
  • + *
  • {@code solid} - Solid objects (cubes, spheres, cylinders)
  • + *
  • {@code wireframe} - Wireframe objects (boxes, grids, spheres)
  • + *
  • {@code textcanvas} - 3D text rendering canvas
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java new file mode 100644 index 0000000..29c2c4e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonArrow.java @@ -0,0 +1,324 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@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
+ * );
+ * }
+ * + * @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. + * + *

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.

+ * + * @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. + * + *

The arrow by default points in the -Y direction. This method computes + * the rotation needed to align the arrow with the target direction vector.

+ * + * @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. + * + *

The cylinder is created with its base at the start point and extends + * in the direction of the arrow for the specified body length.

+ * + *

Local coordinate system: 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).

+ * + * @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. + * + *

The cone is created with its apex at the end point (the arrow tip) + * and its base pointing back towards the start point.

+ * + *

Local coordinate system: 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.

+ * + * @param endPoint the position of the arrow tip (cone apex) + * @param radius the radius of the cone base + * @param length the length of the cone + * @param segments the number of segments around the circumference + * @param color the fill color + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addConeTip(final Point3D endPoint, final double radius, + final double length, final int segments, + final Color color, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Apex is at endPoint (the arrow tip) + // Base center is at endPoint - length * direction (toward arrow start) + final double baseCenterX = endPoint.x - length * dirX; + final double baseCenterY = endPoint.y - length * dirY; + final double baseCenterZ = endPoint.z - length * dirZ; + + // Generate base ring vertices + // In local space, cone points in -Y direction, so base is at Y=0 + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertices at local Y=0 + final Point3D local = new Point3D(localX, 0, localZ); + rotMatrix.transform(local, local); + local.x += baseCenterX; + local.y += baseCenterY; + local.z += baseCenterZ; + baseRing[i] = local; + } + + // Apex point (the arrow tip) + final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z); + + // Create cone side faces + // Winding: apex → current → next creates CCW winding when viewed from outside + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(new SolidPolygon( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z), + color)); + } + + // Create base cap of the cone tip (fills the gap between cone and cylinder body) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // The base cap faces toward the arrow body/start, opposite to the cone's pointing direction. + // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1] + final Point3D baseCenter = new Point3D(baseCenterX, baseCenterY, baseCenterZ); + final Point3D[] tipBaseCapVertices = new Point3D[segments + 2]; + tipBaseCapVertices[0] = baseCenter; + for (int i = 0; i < segments; i++) { + tipBaseCapVertices[i + 1] = baseRing[segments - 1 - i]; + } + tipBaseCapVertices[segments + 1] = baseRing[segments - 1]; // close the loop + addShape(new SolidPolygon(tipBaseCapVertices, color)); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java new file mode 100644 index 0000000..3a4327f --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCone.java @@ -0,0 +1,268 @@ +/* + * 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. + * + *

The cone has a circular base and a single apex (tip) point. Two constructors + * are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): 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.
  • + *
  • Y-axis aligned: Specify base center, radius, and height. The cone + * points in -Y direction (apex at lower Y). Useful for simple vertical cones.
  • + *
+ * + *

Usage examples:

+ *
{@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
+ * );
+ * }
+ * + * @see SolidPolygonCylinder + * @see SolidPolygonArrow + * @see SolidPolygon + */ +public class SolidPolygonCone extends AbstractCompositeShape { + + /** + * Constructs a solid cone pointing from apex toward base center. + * + *

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.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the cone
  • + *
  • {@code baseCenterPoint} - the center of the circular base; the cone + * "points" in this direction from the apex
  • + *
  • The distance between apex and base center determines the cone height
  • + *
+ * + * @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. + * + *

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.

+ * + *

Coordinate system: 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.

+ * + * @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. + * + *

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.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java new file mode 100755 index 0000000..6fa3482 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCube.java @@ -0,0 +1,45 @@ +/* + * 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. + * + *

The cube extends {@code size} units in each direction from the center, + * resulting in a total edge length of {@code 2 * size}.

+ * + *

Usage example:

+ *
{@code
+ * SolidPolygonCube cube = new SolidPolygonCube(
+ *         new Point3D(0, 0, 300), 50, Color.GREEN);
+ * shapeCollection.addShape(cube);
+ * }
+ * + * @see SolidPolygonRectangularBox + * @see Color + */ +public class SolidPolygonCube extends SolidPolygonRectangularBox { + + /** + * Constructs a solid cube centered at the given point. + * + * @param center the center point of the cube in 3D space + * @param size the half-side length; the cube extends this distance from + * the center along each axis, giving a total edge length of + * {@code 2 * size} + * @param color the fill color applied to all faces of the cube + */ + public SolidPolygonCube(final Point3D center, final double size, + final Color color) { + super(new Point3D(center.x - size, center.y - size, center.z - size), + new Point3D(center.x + size, center.y + size, center.z + size), + color); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java new file mode 100644 index 0000000..b4673f6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonCylinder.java @@ -0,0 +1,200 @@ +/* + * 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. + * + *

The cylinder extends from startPoint to endPoint with circular caps at both + * ends. The number of segments determines the smoothness of the curved surface.

+ * + *

Usage example:

+ *
{@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
+ * );
+ * }
+ * + * @see SolidPolygonCone + * @see SolidPolygonArrow + * @see SolidPolygon + */ +public class SolidPolygonCylinder extends AbstractCompositeShape { + + /** + * Constructs a solid cylinder between two end points. + * + *

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.

+ * + * @param startPoint the center of the first cap + * @param endPoint the center of the second cap + * @param radius the radius of the cylinder + * @param segments the number of segments around the circumference. + * Higher values create smoother cylinders. Minimum is 3. + * @param color the fill color applied to all polygons + */ + public SolidPolygonCylinder(final Point3D startPoint, final Point3D endPoint, + final double radius, final int segments, + final Color color) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default cylinder is aligned along Y-axis + // We need to rotate from (0, 1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Cylinder center is at midpoint between start and end + final double centerX = (startPoint.x + endPoint.x) / 2.0; + final double centerY = (startPoint.y + endPoint.y) / 2.0; + final double centerZ = (startPoint.z + endPoint.z) / 2.0; + final double halfLength = distance / 2.0; + + // Generate ring vertices in local space, then rotate and translate + // In local space: cylinder is aligned along Y-axis + // - startSideRing is at local -Y (toward startPoint) + // - endSideRing is at local +Y (toward endPoint) + final Point3D[] startSideRing = new Point3D[segments]; + final Point3D[] endSideRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Start-side ring (at -halfLength in local Y = toward startPoint) + final Point3D startLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(startLocal, startLocal); + startLocal.x += centerX; + startLocal.y += centerY; + startLocal.z += centerZ; + startSideRing[i] = startLocal; + + // End-side ring (at +halfLength in local Y = toward endPoint) + final Point3D endLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(endLocal, endLocal); + endLocal.x += centerX; + endLocal.y += centerY; + endLocal.z += centerZ; + endSideRing[i] = endLocal; + } + + // Create side faces (one quad per segment) + // Winding: startSide[i] → endSide[i] → endSide[next] → startSide[next] + // creates CCW winding when viewed from outside the cylinder + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + addShape(SolidPolygon.quad( + startSideRing[i], + endSideRing[i], + endSideRing[next], + startSideRing[next], + color)); + } + + // Create start cap (at startPoint, faces outward from cylinder) + // Single N-vertex polygon that closes the loop to create segments triangles + // (segments+2 vertices → segments triangles via fan triangulation) + // Winding: center → ring[0] → ring[1] → ... → ring[segments-1] → ring[0] + final Point3D[] startCapVertices = new Point3D[segments + 2]; + startCapVertices[0] = startPoint; + for (int i = 0; i < segments; i++) { + startCapVertices[i + 1] = startSideRing[i]; + } + startCapVertices[segments + 1] = startSideRing[0]; // close the loop + addShape(new SolidPolygon(startCapVertices, color)); + + // Create end cap (at endPoint, faces outward from cylinder) + // Reverse winding for opposite-facing cap + // Winding: center → ring[segments-1] → ... → ring[1] → ring[0] → ring[segments-1] + final Point3D[] endCapVertices = new Point3D[segments + 2]; + endCapVertices[0] = endPoint; + for (int i = 0; i < segments; i++) { + endCapVertices[i + 1] = endSideRing[segments - 1 - i]; + } + endCapVertices[segments + 1] = endSideRing[segments - 1]; // close the loop + addShape(new SolidPolygon(endCapVertices, color)); + + setBackfaceCulling(true); + } + + /** + * Creates a quaternion that rotates from the +Y axis to the given direction. + * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is +Y (0, 1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + 1*ny + 0*nz = ny + final double dot = ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly +Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly -Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx) + // This gives the rotation axis + final double axisX = nz; + final double axisY = 0; + final double axisZ = -nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java new file mode 100644 index 0000000..7481894 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonMesh.java @@ -0,0 +1,61 @@ +/* + * 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. + * + *

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.

+ * + *

Usage:

+ *
{@code
+ * // From list of triangles
+ * List triangles = ...;
+ * SolidPolygonMesh mesh = new SolidPolygonMesh(triangles, location);
+ *
+ * // With fluent configuration
+ * shapes.addShape(mesh.setShadingEnabled(true).setBackfaceCulling(true));
+ * }
+ * + * @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 triangles, final Point3D location) { + super(location); + this.triangleCount = 0; + + for (final SolidPolygon triangle : triangles) { + addShape(triangle); + triangleCount++; + } + } + + /** + * Returns the number of triangles in this mesh. + * + * @return the triangle count + */ + public int getTriangleCount() { + return triangleCount; + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java new file mode 100644 index 0000000..fbf6eb6 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonPyramid.java @@ -0,0 +1,258 @@ +/* + * 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. + * + *

The pyramid has a square base and four triangular faces meeting at an apex + * (tip). Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): 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.
  • + *
  • Y-axis aligned: Specify base center, base size, and height. The pyramid + * points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.
  • + *
+ * + *

Usage examples:

+ *
{@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
+ * );
+ * }
+ * + * @see SolidPolygonCone + * @see SolidPolygonCube + * @see SolidPolygon + */ +public class SolidPolygonPyramid extends AbstractCompositeShape { + + /** + * Constructs a solid square-based pyramid pointing from apex toward base center. + * + *

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.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the pyramid
  • + *
  • {@code baseCenter} - the center of the square base; the pyramid + * "points" in this direction from the apex
  • + *
  • {@code baseSize} - half the width of the square base; the base + * extends this distance from the center along perpendicular axes
  • + *
  • The distance between apex and base center determines the pyramid height
  • + *
+ * + * @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. + * + *

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.

+ * + *

Coordinate system: 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.

+ * + * @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. + * + *

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.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java new file mode 100755 index 0000000..1293c62 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonRectangularBox.java @@ -0,0 +1,122 @@ +/* + * 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). + * + *

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.

+ * + *

Vertex layout:

+ *
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * 
+ * + *

The eight vertices are derived from the two corner points:

+ *
    + *
  • Corner A defines minimum X, Y, Z
  • + *
  • Corner B defines maximum X, Y, Z
  • + *
  • The other 6 vertices are computed from combinations of these coordinates
  • + *
+ * + *

Usage examples:

+ *
{@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
+ * );
+ * }
+ * + * @see SolidPolygonCube + * @see SolidPolygon + */ +public class SolidPolygonRectangularBox extends AbstractCompositeShape { + + /** + * Constructs a solid rectangular box between two diagonally opposite corner + * points in 3D space. + * + *

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.

+ * + * @param cornerA the first corner point (any of the 8 corners) + * @param cornerB the diagonally opposite corner point + * @param color the fill color applied to all 6 quadrilateral polygons + */ + public SolidPolygonRectangularBox(final Point3D cornerA, final Point3D cornerB, final Color color) { + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + // Compute all 8 vertices from the bounds + // Naming convention: min/max indicates which bound the coordinate uses + // minMinMin = (minX, minY, minZ), maxMaxMax = (maxX, maxY, maxZ), etc. + final Point3D minMinMin = new Point3D(minX, minY, minZ); + final Point3D maxMinMin = new Point3D(maxX, minY, minZ); + final Point3D maxMinMax = new Point3D(maxX, minY, maxZ); + final Point3D minMinMax = new Point3D(minX, minY, maxZ); + + final Point3D minMaxMin = new Point3D(minX, maxY, minZ); + final Point3D maxMaxMin = new Point3D(maxX, maxY, minZ); + final Point3D minMaxMax = new Point3D(minX, maxY, maxZ); + final Point3D maxMaxMax = new Point3D(maxX, maxY, maxZ); + + // Bottom face (y = minY) - CCW when viewed from below + addShape(new SolidPolygon(new Point3D[]{minMinMin, maxMinMin, maxMinMax, minMinMax}, color)); + + // Top face (y = maxY) - CCW when viewed from above + addShape(new SolidPolygon(new Point3D[]{minMaxMin, minMaxMax, maxMaxMax, maxMaxMin}, color)); + + // Front face (z = minZ) - CCW when viewed from front + addShape(new SolidPolygon(new Point3D[]{minMinMin, minMaxMin, maxMaxMin, maxMinMin}, color)); + + // Back face (z = maxZ) - CCW when viewed from behind + addShape(new SolidPolygon(new Point3D[]{maxMinMax, maxMaxMax, minMaxMax, minMinMax}, color)); + + // Left face (x = minX) - CCW when viewed from left + addShape(new SolidPolygon(new Point3D[]{minMinMin, minMinMax, minMaxMax, minMaxMin}, color)); + + // Right face (x = maxX) - CCW when viewed from right + addShape(new SolidPolygon(new Point3D[]{maxMinMin, maxMaxMin, maxMaxMax, maxMinMax}, color)); + + setBackfaceCulling(true); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java new file mode 100644 index 0000000..6f0d64d --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/SolidPolygonSphere.java @@ -0,0 +1,84 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see SolidPolygonCube + * @see SolidPolygon + * @see AbstractCompositeShape + */ +public class SolidPolygonSphere extends AbstractCompositeShape { + + /** + * Constructs a solid sphere centered at the given point. + * + * @param center the center point of the sphere in 3D space + * @param radius the radius of the sphere + * @param segments the number of segments (latitude/longitude divisions). + * Higher values create smoother spheres. Minimum is 3. + * @param color the fill color applied to all triangular polygons + */ + public SolidPolygonSphere(final Point3D center, final double radius, + final int segments, final Color color) { + super(); + + final int rings = segments; + final int sectors = segments * 2; + + for (int i = 0; i < rings; i++) { + double lat0 = Math.PI * (-0.5 + (double) i / rings); + double lat1 = Math.PI * (-0.5 + (double) (i + 1) / rings); + + for (int j = 0; j < sectors; j++) { + double lon0 = 2 * Math.PI * (double) j / sectors; + double lon1 = 2 * Math.PI * (double) (j + 1) / sectors; + + Point3D p0 = sphericalToCartesian(center, radius, lat0, lon0); + Point3D p1 = sphericalToCartesian(center, radius, lat0, lon1); + Point3D p2 = sphericalToCartesian(center, radius, lat1, lon0); + Point3D p3 = sphericalToCartesian(center, radius, lat1, lon1); + + if (i > 0) { + addShape(new SolidPolygon(p0, p2, p1, color)); + } + + if (i < rings - 1) { + addShape(new SolidPolygon(p2, p3, p1, color)); + } + } + } + + setBackfaceCulling(true); + } + + private Point3D sphericalToCartesian(final Point3D center, + final double radius, + final double lat, + final double lon) { + double x = center.x + radius * Math.cos(lat) * Math.cos(lon); + double y = center.y + radius * Math.sin(lat); + double z = center.z + radius * Math.cos(lat) * Math.sin(lon); + return new Point3D(x, y, z); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/package-info.java new file mode 100644 index 0000000..d812d4b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/solid/package-info.java @@ -0,0 +1,24 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Solid composite shapes built from SolidTriangle primitives. + * + *

These shapes render as filled surfaces with optional flat shading. + * Useful for creating opaque 3D objects like boxes, spheres, and cylinders.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCube} - A solid cube
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonRectangularBox} - A solid box
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonSphere} - A solid sphere
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonCylinder} - A solid cylinder
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.solid.SolidPolygonPyramid} - A solid pyramid
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/CanvasCharacter.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/CanvasCharacter.java new file mode 100644 index 0000000..989f028 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/CanvasCharacter.java @@ -0,0 +1,215 @@ +/* + * 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. + *

+ * 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.width * 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 < 0) + return; + if (onScreenLocation.y < 0) + return; + + if (onScreenLocation.x > renderingContext.width) + 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; + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/RenderMode.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/RenderMode.java new file mode 100644 index 0000000..b4fc5e7 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/RenderMode.java @@ -0,0 +1,28 @@ +/* + * 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}. + * + *

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.

+ * + * @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 +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java new file mode 100644 index 0000000..6a9d08a --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/TextCanvas.java @@ -0,0 +1,466 @@ +/* + * 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. + * + *

{@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}.

+ * + *

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:

+ *
    + *
  • {@link RenderMode#TEXTURE} -- renders all characters to a shared texture bitmap. + * This is efficient for distant or obliquely viewed text.
  • + *
  • {@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.
  • + *
+ * + *

Usage example

+ *
{@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");
+ * }
+ * + * @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. + * + *

The canvas dimensions are automatically computed from the text content + * (number of lines determines rows, the longest line determines columns).

+ * + * @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. + * + *

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)}.

+ * + * @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. + * + *

Both the character grid and the backing texture bitmap are reset.

+ */ + 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. + * + *

Subsequent calls to {@link #putChar(char)} and {@link #print(String)} will + * begin writing at this position.

+ * + * @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. + * + *

When the cursor reaches the end of a row, it wraps to the beginning of the next row.

+ * + * @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. + * + *

The cursor moves one column to the right. If it exceeds the row width, + * it wraps to column 0 of the next row.

+ * + * @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. + * + *

If the row or column is out of bounds, the call is silently ignored.

+ * + * @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. + * + *

Each line of text (separated by newlines) is written to consecutive rows, + * starting from row 0. Characters beyond the canvas width are ignored.

+ * + * @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. + * + *

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.

+ * + * @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); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java new file mode 100644 index 0000000..cb4f6be --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/textcanvas/package-info.java @@ -0,0 +1,9 @@ +/** + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + *

+ * + * 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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java new file mode 100644 index 0000000..333d920 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid2D.java @@ -0,0 +1,80 @@ +/* + * 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. + * + *

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}.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see Grid3D + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class Grid2D extends AbstractCompositeShape { + + /** + * Constructs a 2D grid in the XY plane with the specified dimensions and + * number of divisions. + * + * @param transform the transform defining the grid's position and orientation + * in world space + * @param rectangle the rectangular dimensions of the grid in local XY space + * @param xDivisionCount the number of divisions (cells) along the X axis; + * produces {@code xDivisionCount + 1} vertical lines + * @param yDivisionCount the number of divisions (cells) along the Y axis; + * produces {@code yDivisionCount + 1} horizontal lines + * @param appearance the line appearance (color, width) used for all grid lines + */ + public Grid2D(final Transform transform, final Rectangle rectangle, + final int xDivisionCount, final int yDivisionCount, + final LineAppearance appearance) { + + super(transform); + + final double stepY = rectangle.getHeight() / yDivisionCount; + final double stepX = rectangle.getWidth() / xDivisionCount; + + for (int ySlice = 0; ySlice <= yDivisionCount; ySlice++) { + final double y = (ySlice * stepY) + rectangle.getLowerY(); + + for (int xSlice = 0; xSlice <= xDivisionCount; xSlice++) { + final double x = (xSlice * stepX) + rectangle.getLowerX(); + + final Point3D p1 = new Point3D(x, y, 0); + final Point3D p2 = new Point3D(x + stepX, y, 0); + final Point3D p3 = new Point3D(x, y + stepY, 0); + + if (xSlice < xDivisionCount) + addShape(appearance.getLine(p1, p2)); + + if (ySlice < yDivisionCount) + addShape(appearance.getLine(p1, p3)); + } + + } + + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java new file mode 100755 index 0000000..4ce0bbb --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/Grid3D.java @@ -0,0 +1,87 @@ +/* + * 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. + * + *

At each grid intersection point, up to three line segments are created + * (one along each axis), forming a three-dimensional lattice.

+ * + *

This shape is useful for visualizing 3D space, voxel boundaries, or + * spatial reference grids in a scene.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see Grid2D + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class Grid3D extends AbstractCompositeShape { + + /** + * Constructs a 3D grid filling the volume between two diagonally opposite + * corner points. + * + *

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.

+ * + * @param cornerA the first corner point defining the volume + * @param cornerB the diagonally opposite corner point + * @param step the spacing between grid lines along each axis; must be positive + * @param appearance the line appearance (color, width) used for all grid lines + */ + public Grid3D(final Point3D cornerA, final Point3D cornerB, final double step, + final LineAppearance appearance) { + + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + for (double x = minX; x <= maxX; x += step) { + for (double y = minY; y <= maxY; y += step) { + for (double z = minZ; z <= maxZ; z += step) { + + final Point3D p = new Point3D(x, y, z); + + // Line along X axis + if ((x + step) <= maxX) { + addShape(appearance.getLine(p, new Point3D(x + step, y, z))); + } + + // Line along Y axis + if ((y + step) <= maxY) { + addShape(appearance.getLine(p, new Point3D(x, y + step, z))); + } + + // Line along Z axis + if ((z + step) <= maxZ) { + addShape(appearance.getLine(p, new Point3D(x, y, z + step))); + } + } + } + } + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java new file mode 100644 index 0000000..69ec6cf --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeArrow.java @@ -0,0 +1,321 @@ +/* + * 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. + * + *

The arrow points from a start point to an end point, with the tip + * located at the end point. The wireframe consists of:

+ *
    + *
  • Body: Two circular rings connected by lines between corresponding vertices
  • + *
  • Tip: A circular ring at the cone base with lines to the apex
  • + *
+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

The arrow by default points in the -Y direction. This method computes + * the rotation needed to align the arrow with the target direction vector.

+ * + * @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. + * + *

Local coordinate system: 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).

+ * + * @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. + * + *

Local coordinate system: 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.

+ * + * @param endPoint the position of the arrow tip (cone apex) + * @param radius the radius of the cone base + * @param length the length of the cone + * @param segments the number of segments around the circumference + * @param appearance the line appearance + * @param rotMatrix the rotation matrix to apply + * @param dirX direction X component + * @param dirY direction Y component + * @param dirZ direction Z component + */ + private void addConeTip(final Point3D endPoint, final double radius, + final double length, final int segments, + final LineAppearance appearance, final Matrix3x3 rotMatrix, + final double dirX, final double dirY, final double dirZ) { + // Apex is at endPoint (the arrow tip) + // Base center is at endPoint - length * direction (toward arrow start) + final double baseCenterX = endPoint.x - length * dirX; + final double baseCenterY = endPoint.y - length * dirY; + final double baseCenterZ = endPoint.z - length * dirZ; + + // Generate base ring vertices + // In local space, cone points in -Y direction, so base is at Y=0 + final Point3D[] baseRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Base ring vertices at local Y=0 + final Point3D local = new Point3D(localX, 0, localZ); + rotMatrix.transform(local, local); + local.x += baseCenterX; + local.y += baseCenterY; + local.z += baseCenterZ; + baseRing[i] = local; + } + + // Apex point (the arrow tip) + final Point3D apex = new Point3D(endPoint.x, endPoint.y, endPoint.z); + + // Create the circular base ring + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + addShape(appearance.getLine( + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z), + new Point3D(baseRing[next].x, baseRing[next].y, baseRing[next].z))); + } + + // Create lines from apex to each base vertex + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(apex.x, apex.y, apex.z), + new Point3D(baseRing[i].x, baseRing[i].y, baseRing[i].z))); + } + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java new file mode 100755 index 0000000..9ff9bef --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeBox.java @@ -0,0 +1,104 @@ +/* + * 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. + * + *

The wireframe consists of four edges along each axis: four edges parallel + * to X, four parallel to Y, and four parallel to Z.

+ * + *

Vertex layout:

+ *
+ *         cornerB (max) ────────┐
+ *              /│              /│
+ *             / │             / │
+ *            /  │            /  │
+ *           ┌───┼───────────┐   │
+ *           │   │           │   │
+ *           │   │           │   │
+ *           │   └───────────│───┘
+ *           │  /            │  /
+ *           │ /             │ /
+ *           │/              │/
+ *           └───────────────┘ cornerA (min)
+ * 
+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @see WireframeCube + * @see Box + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class WireframeBox extends AbstractCompositeShape { + + /** + * Constructs a wireframe box from a {@link Box} geometry object. + * + * @param box the axis-aligned box defining the two opposite corners + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeBox(final Box box, + final LineAppearance appearance) { + + this(box.p1, box.p2, appearance); + } + + /** + * Constructs a wireframe box from two diagonally opposite corner points. + * The corners do not need to be in any particular min/max order; the constructor + * uses each coordinate independently to form all eight vertices of the box. + * + * @param cornerA the first corner point of the box + * @param cornerB the diagonally opposite corner point of the box + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeBox(final Point3D cornerA, final Point3D cornerB, + final LineAppearance appearance) { + super(); + + // Determine actual min/max bounds (corners may be in any order) + final double minX = Math.min(cornerA.x, cornerB.x); + final double maxX = Math.max(cornerA.x, cornerB.x); + final double minY = Math.min(cornerA.y, cornerB.y); + final double maxY = Math.max(cornerA.y, cornerB.y); + final double minZ = Math.min(cornerA.z, cornerB.z); + final double maxZ = Math.max(cornerA.z, cornerB.z); + + // Generate the 12 edges of the box + // Four edges along X axis (varying X, fixed Y and Z) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(maxX, minY, minZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(maxX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(maxX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, maxZ), new Point3D(maxX, maxY, maxZ))); + + // Four edges along Y axis (varying Y, fixed X and Z) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, maxY, minZ))); + addShape(appearance.getLine(new Point3D(minX, minY, maxZ), new Point3D(minX, maxY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, maxZ), new Point3D(maxX, maxY, maxZ))); + + // Four edges along Z axis (varying Z, fixed X and Y) + addShape(appearance.getLine(new Point3D(minX, minY, minZ), new Point3D(minX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, minY, minZ), new Point3D(maxX, minY, maxZ))); + addShape(appearance.getLine(new Point3D(minX, maxY, minZ), new Point3D(minX, maxY, maxZ))); + addShape(appearance.getLine(new Point3D(maxX, maxY, minZ), new Point3D(maxX, maxY, maxZ))); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java new file mode 100644 index 0000000..76a31b2 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCone.java @@ -0,0 +1,247 @@ +/* + * 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. + * + *

The cone has a circular base and a single apex (tip) point. The wireframe + * consists of:

+ *
    + *
  • A circular ring at the base
  • + *
  • Lines from each base vertex to the apex
  • + *
+ * + *

Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): 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.
  • + *
  • Y-axis aligned: Specify base center, radius, and height. The cone + * points in -Y direction (apex at lower Y). Useful for simple vertical cones.
  • + *
+ * + *

Usage examples:

+ *
{@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
+ * );
+ * }
+ * + * @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. + * + *

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.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the cone
  • + *
  • {@code baseCenterPoint} - the center of the circular base; the cone + * "points" in this direction from the apex
  • + *
  • The distance between apex and base center determines the cone height
  • + *
+ * + * @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. + * + *

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.

+ * + *

Coordinate system: 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.

+ * + * @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. + * + *

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.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java new file mode 100755 index 0000000..5f31fd3 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCube.java @@ -0,0 +1,45 @@ +/* + * 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. + * + *

The cube extends {@code size} units in each direction from the center, + * resulting in a total edge length of {@code 2 * size}.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.CYAN);
+ * WireframeCube cube = new WireframeCube(new Point3D(0, 0, 200), 50, appearance);
+ * shapeCollection.addShape(cube);
+ * }
+ * + * @see WireframeBox + * @see LineAppearance + */ +public class WireframeCube extends WireframeBox { + + /** + * Constructs a wireframe cube centered at the given point. + * + * @param center the center point of the cube in 3D space + * @param size the half-side length; the cube extends this distance from + * the center along each axis, giving a total edge length + * of {@code 2 * size} + * @param appearance the line appearance (color, width) used for all 12 edges + */ + public WireframeCube(final Point3D center, final double size, + final LineAppearance appearance) { + super(new Point3D(center.x - size, center.y - size, center.z - size), + new Point3D(center.x + size, center.y + size, center.z + size), + appearance); + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java new file mode 100644 index 0000000..7bd1381 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeCylinder.java @@ -0,0 +1,188 @@ +/* + * 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. + * + *

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:

+ *
    + *
  • Two circular rings at the start and end points
  • + *
  • Vertical lines connecting corresponding vertices between the rings
  • + *
+ * + *

Usage example:

+ *
{@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
+ * );
+ * }
+ * + * @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. + * + *

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.

+ * + * @param startPoint the center of the first ring + * @param endPoint the center of the second ring + * @param radius the radius of the cylinder + * @param segments the number of segments around the circumference. + * Higher values create smoother cylinders. Minimum is 3. + * @param appearance the line appearance (color, width) used for all lines + */ + public WireframeCylinder(final Point3D startPoint, final Point3D endPoint, + final double radius, final int segments, + final LineAppearance appearance) { + super(); + + // Calculate direction and distance + final double dx = endPoint.x - startPoint.x; + final double dy = endPoint.y - startPoint.y; + final double dz = endPoint.z - startPoint.z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + // Handle degenerate case: start and end are the same point + if (distance < 0.001) { + return; + } + + // Normalize direction vector + final double nx = dx / distance; + final double ny = dy / distance; + final double nz = dz / distance; + + // Calculate rotation to align Y-axis with direction + // Default cylinder is aligned along Y-axis + // We need to rotate from (0, 1, 0) to (nx, ny, nz) + final Quaternion rotation = createRotationFromYAxis(nx, ny, nz); + final Matrix3x3 rotMatrix = rotation.toMatrix(); + + // Cylinder center is at midpoint between start and end + final double centerX = (startPoint.x + endPoint.x) / 2.0; + final double centerY = (startPoint.y + endPoint.y) / 2.0; + final double centerZ = (startPoint.z + endPoint.z) / 2.0; + final double halfLength = distance / 2.0; + + // Generate ring vertices in local space, then rotate and translate + // In local space: cylinder is aligned along Y-axis + // - startRing is at local -Y (toward startPoint) + // - endRing is at local +Y (toward endPoint) + final Point3D[] startRing = new Point3D[segments]; + final Point3D[] endRing = new Point3D[segments]; + + for (int i = 0; i < segments; i++) { + final double angle = 2.0 * Math.PI * i / segments; + final double localX = radius * Math.cos(angle); + final double localZ = radius * Math.sin(angle); + + // Start ring (at -halfLength in local Y = toward startPoint) + final Point3D startLocal = new Point3D(localX, -halfLength, localZ); + rotMatrix.transform(startLocal, startLocal); + startLocal.x += centerX; + startLocal.y += centerY; + startLocal.z += centerZ; + startRing[i] = startLocal; + + // End ring (at +halfLength in local Y = toward endPoint) + final Point3D endLocal = new Point3D(localX, halfLength, localZ); + rotMatrix.transform(endLocal, endLocal); + endLocal.x += centerX; + endLocal.y += centerY; + endLocal.z += centerZ; + endRing[i] = endLocal; + } + + // Create the circular rings + for (int i = 0; i < segments; i++) { + final int next = (i + 1) % segments; + + // Start ring line segment + addShape(appearance.getLine( + new Point3D(startRing[i].x, startRing[i].y, startRing[i].z), + new Point3D(startRing[next].x, startRing[next].y, startRing[next].z))); + + // End ring line segment + addShape(appearance.getLine( + new Point3D(endRing[i].x, endRing[i].y, endRing[i].z), + new Point3D(endRing[next].x, endRing[next].y, endRing[next].z))); + } + + // Create vertical lines connecting the two rings + for (int i = 0; i < segments; i++) { + addShape(appearance.getLine( + new Point3D(startRing[i].x, startRing[i].y, startRing[i].z), + new Point3D(endRing[i].x, endRing[i].y, endRing[i].z))); + } + } + + /** + * Creates a quaternion that rotates from the +Y axis to the given direction. + * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is +Y (0, 1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + 1*ny + 0*nz = ny + final double dot = ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly +Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly -Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, 1, 0) x (nx, ny, nz) = (nz, 0, -nx) + // This gives the rotation axis + final double axisX = nz; + final double axisY = 0; + final double axisZ = -nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java new file mode 100755 index 0000000..a015852 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeDrawing.java @@ -0,0 +1,75 @@ +/* + * 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. + * + *

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.

+ * + *

This shape is useful for drawing paths, trails, trajectories, or + * arbitrary wireframe shapes that are defined as a sequence of vertices.

+ * + *

Usage example:

+ *
{@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);
+ * }
+ * + * @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. + * + *

The point is defensively copied, so subsequent modifications to the + * passed {@code point3d} object will not affect the drawing.

+ * + * @param point3d the point to add to the polyline + */ + public void addPoint(final Point3D point3d) { + if (currentPoint != null) { + final Line line = lineAppearance.getLine(currentPoint, point3d); + addShape(line); + } + + currentPoint = new Point3D(point3d); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java new file mode 100644 index 0000000..242cc03 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframePyramid.java @@ -0,0 +1,246 @@ +/* + * 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. + * + *

The pyramid has a square base and four triangular faces meeting at an apex + * (tip). The wireframe consists of:

+ *
    + *
  • Four lines forming the square base
  • + *
  • Four lines from each base corner to the apex
  • + *
+ * + *

Two constructors are provided for different use cases:

+ * + *
    + *
  • Directional (recommended): 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.
  • + *
  • Y-axis aligned: Specify base center, base size, and height. The pyramid + * points in -Y direction (apex at lower Y). Useful for simple vertical pyramids.
  • + *
+ * + *

Usage examples:

+ *
{@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
+ * );
+ * }
+ * + * @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. + * + *

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.

+ * + *

Coordinate interpretation:

+ *
    + *
  • {@code apexPoint} - the sharp tip of the pyramid
  • + *
  • {@code baseCenter} - the center of the square base; the pyramid + * "points" in this direction from the apex
  • + *
  • {@code baseSize} - half the width of the square base; the base + * extends this distance from the center along perpendicular axes
  • + *
  • The distance between apex and base center determines the pyramid height
  • + *
+ * + * @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. + * + *

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.

+ * + *

Coordinate system: 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.

+ * + * @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. + * + *

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.

+ * + * @param nx normalized direction X component + * @param ny normalized direction Y component + * @param nz normalized direction Z component + * @return quaternion representing the rotation + */ + private Quaternion createRotationFromYAxis(final double nx, final double ny, final double nz) { + // Default direction is -Y (0, -1, 0) + // Target direction is (nx, ny, nz) + // Dot product: 0*nx + (-1)*ny + 0*nz = -ny + final double dot = -ny; + + // Check for parallel vectors + if (dot > 0.9999) { + // Direction is nearly -Y, no rotation needed + return Quaternion.identity(); + } + if (dot < -0.9999) { + // Direction is nearly +Y, rotate 180° around X axis + return Quaternion.fromAxisAngle(new Point3D(1, 0, 0), Math.PI); + } + + // Cross product: (0, -1, 0) x (nx, ny, nz) = (-nz, 0, nx) + // This gives the rotation axis + final double axisX = -nz; + final double axisY = 0; + final double axisZ = nx; + final double axisLength = Math.sqrt(axisX * axisX + axisY * axisY + axisZ * axisZ); + final double normalizedAxisX = axisX / axisLength; + final double normalizedAxisZ = axisZ / axisLength; + + // Angle from dot product + final double angle = Math.acos(dot); + + return Quaternion.fromAxisAngle( + new Point3D(normalizedAxisX, 0, normalizedAxisZ), angle); + } +} \ No newline at end of file diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java new file mode 100755 index 0000000..6c885f4 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/WireframeSphere.java @@ -0,0 +1,87 @@ +/* + * 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. + * + *

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.

+ * + *

Usage example:

+ *
{@code
+ * LineAppearance appearance = new LineAppearance(1, Color.WHITE);
+ * WireframeSphere sphere = new WireframeSphere(new Point3D(0, 0, 300), 100f, appearance);
+ * shapeCollection.addShape(sphere);
+ * }
+ * + * @see LineAppearance + * @see AbstractCompositeShape + */ +public class WireframeSphere extends AbstractCompositeShape { + + /** Stores the vertices of the previously generated ring for inter-ring connections. */ + ArrayList previousRing = new ArrayList<>(); + + /** + * Constructs a wireframe sphere at the given location with the specified radius. + * The sphere is approximated by a grid of line segments generated from + * parametric spherical coordinates. + * + * @param location the center point of the sphere in 3D space + * @param radius the radius of the sphere + * @param lineFactory the line appearance (color, width) used for all line segments + */ + public WireframeSphere(final Point3D location, final float radius, + final LineAppearance lineFactory) { + super(location); + + final double step = Math.PI / 10; + + final Point3D center = new Point3D(); + + int ringIndex = 0; + + for (double j = 0d; j <= (Math.PI * 2); j += step) { + + Point3D oldPoint = null; + int pointIndex = 0; + + for (double i = 0; i <= (Math.PI * 2); i += step) { + final Point3D newPoint = new Point3D(0, 0, radius); + newPoint.rotate(center, i, j); + + if (oldPoint != null) + addShape(lineFactory.getLine(newPoint, oldPoint)); + + if (ringIndex > 0) { + final Point3D previousRingPoint = previousRing + .get(pointIndex); + addShape(lineFactory.getLine(newPoint, previousRingPoint)); + + previousRing.set(pointIndex, newPoint); + } else + previousRing.add(newPoint); + + oldPoint = newPoint; + pointIndex++; + } + + ringIndex++; + } + + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/package-info.java new file mode 100644 index 0000000..b96b561 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/composite/wireframe/package-info.java @@ -0,0 +1,24 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Wireframe composite shapes built from Line primitives. + * + *

These shapes render as edge-only outlines, useful for visualization, + * debugging, and architectural-style rendering.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeBox} - A wireframe box
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeCube} - A wireframe cube
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.WireframeSphere} - A wireframe sphere
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.Grid2D} - A 2D grid plane
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.composite.wireframe.Grid3D} - A 3D grid volume
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/package-info.java new file mode 100644 index 0000000..1d88c7e --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/shapes/package-info.java @@ -0,0 +1,25 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Renderable shape classes for the rasterization pipeline. + * + *

This package contains the shape hierarchy used for 3D rendering:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractShape} - Base class for all shapes
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.shapes.AbstractCoordinateShape} - Base for shapes with vertices
  • + *
+ * + *

Subpackages organize shapes by type:

+ *
    + *
  • {@code basic} - Primitive shapes (lines, polygons, billboards)
  • + *
  • {@code composite} - Compound shapes built from primitives (boxes, grids, text)
  • + *
+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TessellationEdge.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TessellationEdge.java new file mode 100644 index 0000000..ff1ef8b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TessellationEdge.java @@ -0,0 +1,81 @@ +/* + * 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. + * + *

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.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.java new file mode 100644 index 0000000..8aab271 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/TexturedPolygonTessellator.java @@ -0,0 +1,153 @@ +/* + * 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. + * + *

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}.

+ * + *

The tessellation algorithm works as follows:

+ *
    + *
  1. For a given triangle, compute the lengths of all three edges.
  2. + *
  3. Sort edges by length and find the longest one.
  4. + *
  5. If the longest edge is shorter than {@code maxDistance}, emit the triangle as-is.
  6. + *
  7. Otherwise, split the longest edge at its midpoint (interpolating both 3D and + * texture coordinates) and recurse on the two resulting sub-triangles.
  8. + *
+ * + *

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.

+ * + * @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 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 getResult() { + return result; + } + + /** + * Tessellates the given textured polygon into smaller triangles. + * + *

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.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/package-info.java new file mode 100644 index 0000000..81d45cb --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/tessellation/package-info.java @@ -0,0 +1,17 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Triangle tessellation for perspective-correct texture rendering. + * + *

Large textured triangles are tessellated into smaller triangles to ensure + * accurate perspective correction. This package provides the recursive tessellation + * algorithm used by composite shapes.

+ * + * @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 diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/Texture.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/Texture.java new file mode 100644 index 0000000..ef1af86 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/Texture.java @@ -0,0 +1,421 @@ +/* + * 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. + * + *

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.

+ * + *

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.

+ * + *

Mipmap levels

+ *
    + *
  • Primary bitmap -- the native resolution; always available.
  • + *
  • Downsampled bitmaps -- up to 8 levels, each half the size of the previous. + * Used when the texture is rendered at zoom levels below 1.0.
  • + *
  • Upsampled bitmaps -- 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.
  • + *
+ * + *

Usage example

+ *
{@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);
+ * }
+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

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.

+ * + * @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. + * + *

Scale factor represents how large the texture appears on screen + * relative to its native resolution:

+ *
    + *
  • scale < 1.0: texture appears smaller (use downscaled mipmap)
  • + *
  • scale 1.0-2.0: texture appears near native size (use primary bitmap)
  • + *
  • scale > 2.0: texture appears much larger (use upscaled mipmap)
  • + *
+ * + * @param scale the apparent scale factor of the texture on screen + * @return the best-fit mipmap level as a {@link TextureBitmap} + */ + public TextureBitmap getMipmapForScale(final double scale) { + + if (scale < 1) { + final int mipmapLevel = getDownscaleMipmapLevel(scale); + return getDownscaledBitmap(mipmapLevel); + } else if (scale > 2) { + final int mipmapLevel = getUpscaleMipmapLevel(scale); + + if (mipmapLevel < 0) + return primaryBitmap; + + return getUpscaledBitmap(mipmapLevel); + } + + return primaryBitmap; + } + + /** + * Resets the cache of resampled bitmaps + */ + public void resetResampledBitmapCache() { + fill(upSampled, null); + + fill(downSampled, null); + } + + /** + * Upscales the given bitmap by a factor of 2 + * + * @param originalBitmap The bitmap to upscale + * @return The upscaled bitmap + */ + public TextureBitmap upscaleBitmap(final TextureBitmap originalBitmap) { + final int srcW = originalBitmap.width; + final int srcH = originalBitmap.height; + final int newWidth = srcW * 2; + final int newHeight = srcH * 2; + final int srcWMinus1 = srcW - 1; + final int srcHMinus1 = srcH - 1; + + final TextureBitmap upScaled = new TextureBitmap(newWidth, newHeight, + originalBitmap.multiplicationFactor * 2d); + + final int[] src = originalBitmap.pixels; + final int[] dst = upScaled.pixels; + + for (int y = 0; y < srcH; y++) { + final int srcRowOffset = y * srcW; + final int nextRowOffset = Math.min(y + 1, srcHMinus1) * srcW; + final int dstRow0Offset = (y * 2) * newWidth; + final int dstRow1Offset = (y * 2 + 1) * newWidth; + + for (int x = 0; x < srcW; x++) { + final int nx = Math.min(x + 1, srcWMinus1); + + final int p00 = src[srcRowOffset + x]; + final int p10 = src[srcRowOffset + nx]; + final int p01 = src[nextRowOffset + x]; + final int p11 = src[nextRowOffset + nx]; + + dst[dstRow0Offset + x * 2] = p00; + dst[dstRow0Offset + x * 2 + 1] = avg2(p00, p10); + dst[dstRow1Offset + x * 2] = avg2(p00, p01); + dst[dstRow1Offset + x * 2 + 1] = avg4(p00, p10, p01, p11); + } + } + + return upScaled; + } + + private static int avg2(final int p0, final int p1) { + return (((((p0 >>> 24) + (p1 >>> 24)) >> 1) << 24) + | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff)) >> 1) << 16) + | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff)) >> 1) << 8) + | (((p0 & 0xff) + (p1 & 0xff)) >> 1)); + } + + private static int avg4(final int p0, final int p1, final int p2, final int p3) { + return ((((p0 >>> 24) + (p1 >>> 24) + (p2 >>> 24) + (p3 >>> 24)) >> 2) << 24) + | (((((p0 >> 16) & 0xff) + ((p1 >> 16) & 0xff) + ((p2 >> 16) & 0xff) + ((p3 >> 16) & 0xff)) >> 2) << 16) + | (((((p0 >> 8) & 0xff) + ((p1 >> 8) & 0xff) + ((p2 >> 8) & 0xff) + ((p3 >> 8) & 0xff)) >> 2) << 8) + | (((p0 & 0xff) + (p1 & 0xff) + (p2 & 0xff) + (p3 & 0xff)) >> 2); + } + + /** + * A helper class that accumulates color values for a given area of a bitmap. + */ + public static class ColorAccumulator { + /** Accumulated red component. */ + public int r; + /** Accumulated green component. */ + public int g; + /** Accumulated blue component. */ + public int b; + /** Accumulated alpha component. */ + public int a; + + /** Number of pixels accumulated. */ + public int pixelCount = 0; + + /** + * Creates a new color accumulator with zero values. + */ + public ColorAccumulator() { + } + + /** + * Accumulates the color values of the given pixel + * + * @param bitmap The bitmap + * @param x The x coordinate of the pixel + * @param y The y coordinate of the pixel + */ + public void accumulate(final TextureBitmap bitmap, final int x, + final int y) { + final int pixel = bitmap.pixels[bitmap.getAddress(x, y)]; + a += (pixel >> 24) & 0xff; + r += (pixel >> 16) & 0xff; + g += (pixel >> 8) & 0xff; + b += pixel & 0xff; + pixelCount++; + } + + /** + * Resets the accumulator + */ + public void reset() { + a = 0; + r = 0; + g = 0; + b = 0; + pixelCount = 0; + } + + /** + * Stores the accumulated color values in the given bitmap + * + * @param bitmap The bitmap + * @param x The x coordinate of the pixel + * @param y The y coordinate of the pixel + */ + public void storeResult(final TextureBitmap bitmap, final int x, + final int y) { + final int avgA = a / pixelCount; + final int avgR = r / pixelCount; + final int avgG = g / pixelCount; + final int avgB = b / pixelCount; + bitmap.pixels[bitmap.getAddress(x, y)] = (avgA << 24) | (avgR << 16) | (avgG << 8) | avgB; + } + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/TextureBitmap.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/TextureBitmap.java new file mode 100644 index 0000000..ac0e1f0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/TextureBitmap.java @@ -0,0 +1,291 @@ +/* + * 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. + * + *

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.

+ * + *

{@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.

+ * + *

This class provides low-level pixel operations including:

+ *
    + *
  • Alpha-blended pixel transfer to a target raster ({@link #drawPixel(int, int[], int)})
  • + *
  • Direct pixel writes using engine {@link Color} ({@link #drawPixel(int, int, Color)})
  • + *
  • Filled rectangle drawing ({@link #drawRectangle(int, int, int, int, Color)})
  • + *
  • Full-surface color fill ({@link #fillColor(Color)})
  • + *
+ * + * @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. + * + *

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.

+ * + * @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. + * + *

The pixel data array is initialized to all zeros (fully transparent black).

+ * + * @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. + * + *

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.

+ * + *

Performance note: Uses bit-shift instead of division for alpha blending, + * and pre-multiplies source colors to reduce per-pixel operations.

+ * + * @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. + * + *

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}.

+ * + * @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. + * + *

The color components are written directly without alpha blending. + * Coordinates are clamped to the bitmap bounds by {@link #getAddress(int, int)}.

+ * + * @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. + * + *

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.

+ * + *

Performance: Uses {@link java.util.Arrays#fill(int[], int, int, int)} + * per scanline for optimal JVM-optimized memory writes.

+ * + * @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. + * + *

Every pixel in the bitmap is set to the given color value, + * overwriting all existing content.

+ * + * @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}). + * + *

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.

+ * + * @param x the x coordinate of the pixel + * @param y the y coordinate of the pixel + * @return the index into the pixels array for the specified pixel + */ + public int getAddress(int x, int y) { + if (x < 0) + x = 0; + + if (x >= width) + x = width - 1; + + if (y < 0) + y = 0; + + if (y >= height) + y = height - 1; + + return (y * width) + x; + } +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/package-info.java b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/package-info.java new file mode 100644 index 0000000..848a83b --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/renderer/raster/texture/package-info.java @@ -0,0 +1,22 @@ +/* + * 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. + * + *

Textures provide 2D image data that can be mapped onto polygons. The mipmap + * system automatically generates scaled versions for efficient rendering at + * various distances.

+ * + *

Key classes:

+ *
    + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture} - Main texture class with mipmap support
  • + *
  • {@link eu.svjatoslav.sixth.e3d.renderer.raster.texture.TextureBitmap} - Raw pixel data for a single mipmap level
  • + *
+ * + * @see eu.svjatoslav.sixth.e3d.renderer.raster.texture.Texture + */ + +package eu.svjatoslav.sixth.e3d.renderer.raster.texture; \ No newline at end of file diff --git a/src/main/resources/eu/svjatoslav/sixth/e3d/examples/hourglass.png b/src/main/resources/eu/svjatoslav/sixth/e3d/examples/hourglass.png new file mode 100644 index 0000000000000000000000000000000000000000..47a1638610ced7ff9f6bf98d7f98c8d923343836 GIT binary patch literal 2161 zcmZ`)X*AT07yivy!fO~?3<@QZJ+e#_jb<>tvai{)4w5Wm-)6*1j9p|I*-F{B5XKTo zNwz4m56PPlMvO6Def)p;pL?GBocrASoO8e2a~~QT>OnY!IRF5FVDwR@CoKL4Y^*0a zAS`V6gqX3&TSx$?PyLJT#Bx%D0!;OEfSQk@YkwwP^i6L8K$sK&;9~)Rb|T@w13;h> z0Q`Fg0N{@SK+w0K%>;4MV0Hdm4+R|mi6U}&=83}Yt8Wtk0DtlQ0SL$|5CVWx?idu( z+`9Lr)m>xrRajJKz*L-D&Y;?Bh4m7G&~iybOv^_hwCb;%(}$v&T?IS3739_%u)*Be;jdzLQuT5= zd0GLT+N(#MJBZE0sQK`@R0&Qv9G>?0v1H-3aJ3z;-ia&8$;mr04Tya6n^LBf8^vh^ z^D#VfMbmI8WNt_(IET`zkSS9vtEgD(o<3yxC^M4+2Kv90Azr&GDn80N|IT^h^y!UI za!E-;w@-`oKtt6T6MiC*h)?L$ATbr4DiUPwxs)Zn#ZZ1U$c4zg&!imGPO@4cWsDuEIzDQ(Nc!vlg!@N*FSL^V<#R8$Dz zxL;GK*UQHxBqRtp_-w9bz_%>r{y2f|*RZJlQZdTz+IU{6p7zDOob2WapBBo_aQ-50 z&uM@XCy6oUXAyl{G~wkmA_C>(OU}wNm$PdHh!n(IhW|QC(uBW{9uoPbDK1X!92gaw zs>Ns>`9U2MEUvipuEC(^PFf5ymmggmKk84>U9Fj|eHsljEWH`!J$T@Q`VL{^496CN0pC}4z8a#fA&VXM77wYf>;}(p#p{~r@KN!~ ztdH0lBpAOkm`zg2<>kPcn5bjJ=E?;Kes=9akmhx7&ej6;C8926WML$6 zd3jkY^PQbIhLNqEo%JChq8|(tRaZj>t(IRf@W*F`z6!x$Hdbb~wz~^GOrb?x(f#09 zSS_$cx?ihz_;YVz$hP`slf><>v$I1Y$^Et4FV;2*e#i>HRQYn7$*9N+63sb4$7 z&268l^_e%`+4Pc>vgyo7@T9C+RPtaWRD=9H$*Gc7wdb~KZrA8O^!e}3 z@;a!Z&r}9&xDh)Xf;byUPYvE81wXH;iCW^Gnwy)8UijJK$Ru84o%@@b)7+1Tu^2mJUxdn5k)WVLPSIXIj(>aac1Hi0dBdHFaQ z;anxHf!p6w$QiuQ($X^C{IhnfikVN@LEI4f&=2uYSl-Rm&BPDG4uL>S?%CT@0)9+B zU7sNzF0ux>e`$`qI*HgZa1IQlPRLr-jBJ-%LZuV>l7x9xrxhWFP??+ei%9K3W>{?b zZF6(F6c54AEGJK<8jVITgfgbeBnJnzBPm$tTleHc3ZFEQory>jepOXf{HkU%Zq1bO zMqV+p!E;opP6&p=DvWa~|LwBk7i-hDRI4AKW0cb>481R&qtz;NSy)K0f$0SN zvzITaR;Ren&RCl`oVDCqmJt^qleu@(dD4@@!ozK79jax|5L|k}+%W{eyo4PYJ33eB zCmK?yrxStr;La!6C8jlBmyRdK=TGW!Y=t1U4kG-Gvkzg`6&1Wfl(ekJY}J-m!(dLF zs^5OK3Rzf4Z=^(FVxEt7Uqv9~FpOOGfq@Tw9ZgMyPoJ_J+iCXUn>d^T^olT9oUI^y z1X5Bj0&xL6=!EVaD2j(ExXqPhxN2ZDtWkf5LGRkz-Hn}BS>$vHbrV#uPps{V$KHyZF1E5O7&ZSw&t+P5!c~xw1O^iV9p=O;$+- luA~HGGCKGl!@c{ip6;Rl-{9f!8+~E`Fla+mjn18z{{SeC1P}lK literal 0 HcmV?d00001 diff --git a/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLineTest.java b/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLineTest.java new file mode 100644 index 0000000..ac5f0dc --- /dev/null +++ b/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/TextLineTest.java @@ -0,0 +1,116 @@ +/* + * 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()); + } + +} diff --git a/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java b/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java new file mode 100644 index 0000000..d95fa10 --- /dev/null +++ b/src/test/java/eu/svjatoslav/sixth/e3d/gui/textEditorComponent/package-info.java @@ -0,0 +1,13 @@ +/* + * Sixth 3D engine. Author: Svjatoslav Agejenko. + * This project is released under Creative Commons Zero (CC0) license. + */ + +/** + * Unit tests for the text editor component. + * + *

Tests for {@link eu.svjatoslav.sixth.e3d.gui.textEditorComponent.TextLine} + * and related text processing functionality.

+ */ + +package eu.svjatoslav.sixth.e3d.gui.textEditorComponent; \ No newline at end of file diff --git a/src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java b/src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java new file mode 100644 index 0000000..db4567a --- /dev/null +++ b/src/test/java/eu/svjatoslav/sixth/e3d/math/QuaternionTest.java @@ -0,0 +1,59 @@ +/* + * 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 -- 2.20.1

+ * See also: Let's remove Quaternions from every 3D Engine + * + * @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 lerp(final Point3D other, final double t) { + return new Point3D( + x + (other.x - x) * t, + y + (other.y - y) * t, + z + (other.z - z) * t + ); + } + + /** + * Returns a new point with coordinates multiplied by a factor. + * This point is not modified. + * + * @param factor the multiplier + * @return a new Point3D with multiplied coordinates + * @see #multiply(double) for the mutating version + */ + public Point3D withMultiplied(final double factor) { + return new Point3D(x * factor, y * factor, z * factor); + } + + /** + * Returns a new point with coordinates divided by a factor. + * This point is not modified. + * + * @param factor the divisor + * @return a new Point3D with divided coordinates + * @see #divide(double) for the mutating version + */ + public Point3D withDivided(final double factor) { + return new Point3D(x / factor, y / factor, z / factor); + } + +} diff --git a/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Polygon.java b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Polygon.java new file mode 100644 index 0000000..993b9c0 --- /dev/null +++ b/src/main/java/eu/svjatoslav/sixth/e3d/geometry/Polygon.java @@ -0,0 +1,83 @@ +/* + * 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. + * + *