From: Svjatoslav Agejenko Date: Sat, 4 Apr 2026 09:52:55 +0000 (+0300) Subject: Initial commit X-Git-Tag: sixth-3d-1.4~2 X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=e1ac92399cdd99b62964ef058c507c386cc51eb3;p=sixth-3d.git Initial commit --- e1ac92399cdd99b62964ef058c507c386cc51eb3 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 0000000..8dc1148 Binary files /dev/null and b/doc/Developer tools.png differ diff --git a/doc/Example.png b/doc/Example.png new file mode 100644 index 0000000..7094240 Binary files /dev/null and b/doc/Example.png differ 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 0000000..8d3722b Binary files /dev/null and b/doc/perspective-correct-textures/Affine distortion.png differ diff --git a/doc/perspective-correct-textures/Slices.png b/doc/perspective-correct-textures/Slices.png new file mode 100644 index 0000000..bd41a2b Binary files /dev/null and b/doc/perspective-correct-textures/Slices.png differ 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. + *

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

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 0000000..47a1638 Binary files /dev/null and b/src/main/resources/eu/svjatoslav/sixth/e3d/examples/hourglass.png differ 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