diff --git a/.gitignore b/.gitignore
index 965fe30..e8898fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,5 +7,7 @@
/src/test/skriptparticle/
CLAUDE.md
/.claude
-/shapes-lib/build
-/skript-particle/build
+/shapes-lib/build/
+/skript-particle/build/
+/skript-particle/.gradle/
+/net/minecraft/network
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 41d9927..d64cd49 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index a595206..37f78a6 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 1b6c787..1aa94a4 100644
--- a/gradlew
+++ b/gradlew
@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,13 +80,11 @@ do
esac
done
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-APP_NAME="Gradle"
+# This is normally unused
+# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -133,22 +131,29 @@ location of your Java installation."
fi
else
JAVACMD=java
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then
done
fi
-# Collect all arguments for the java command;
-# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
-# shell script including quotes and variable substitutions, so put them in
-# double quotes to make sure that they get re-expanded; and
-# * put everything else in single quotes, so that it's not re-expanded.
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
@@ -205,6 +214,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
diff --git a/gradlew.bat b/gradlew.bat
index 27f5d3b..93e3f59 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -14,10 +14,10 @@
@rem limitations under the License.
@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
-@rem Gradle startup script for Windows
+@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@@ -25,7 +25,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/settings.gradle b/settings.gradle
index bd0f6f2..818aba4 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -1,2 +1,11 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ maven {
+ url 'https://repo.papermc.io/repository/maven-public/'
+ }
+ }
+}
+
rootProject.name = 'skript-particle-root'
include 'shapes-lib', 'skript-particle'
diff --git a/shapes-lib/build.gradle b/shapes-lib/build.gradle
index b5aef52..d377622 100644
--- a/shapes-lib/build.gradle
+++ b/shapes-lib/build.gradle
@@ -10,6 +10,13 @@ repositories {
dependencies {
api "org.joml:joml:${jomlVersion}"
+ compileOnly 'org.jetbrains:annotations:24.0.1'
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
+ testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
}
java {
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/Easable.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/Easable.java
new file mode 100644
index 0000000..cde3f41
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/Easable.java
@@ -0,0 +1,18 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Something that has an {@link EasingFunction}.
+ */
+public interface Easable {
+
+ /**
+ * @return the easing function used to distribute rotation along the axis
+ */
+ EasingFunction getEasing();
+
+ /**
+ * @param easing the easing function used to distribute rotation along the axis
+ */
+ void setEasing(EasingFunction easing);
+
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/EasingFunction.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/EasingFunction.java
new file mode 100644
index 0000000..ff4a985
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/EasingFunction.java
@@ -0,0 +1,165 @@
+package com.sovdee.shapes.modifiers;
+
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Maps a normalised value {@code t ∈ [0, 1]} to a (typically) {@code [0, 1]} output, shaping
+ * how interpolation progresses along a modifier's input axis.
+ *
+ * Built-in constants and factory methods cover the most common curves, like {@link #SINE_IN_OUT}.
+ *
+ * Custom implementations must provide a stable {@link #easingHash()} so that modifier cache
+ * invalidation works correctly.
+ */
+public interface EasingFunction {
+
+ /**
+ * Given an input between 0 and 1, applies the easing function and returns a mapped output
+ * @param t Input between 0 and 1.
+ * @return Output value mapped by the easing function.
+ */
+ double apply(double t);
+
+ /**
+ * @return Stable hash of this easing's configuration, used in modifier cache keys.
+ * Two easings that produce identical output for all inputs should return the same value.
+ */
+ int easingHash();
+
+ /**
+ * Controls which end of the interpolation range is eased.
+ *
+ * - {@link #IN} — slow start, fast end (ease into motion)
+ * - {@link #OUT} — fast start, slow end (ease out of motion)
+ * - {@link #IN_OUT} — slow start and slow end (symmetric ease)
+ *
+ */
+ enum Mode {
+ IN,
+ OUT,
+ IN_OUT
+ }
+
+ /**
+ * Power-curve easing: {@code t^n} (in), {@code 1-(1-t)^n} (out),
+ * symmetric combination (in-out).
+ * @param exponent 1 = linear, 2 = quadratic, 3 = cubic, etc.
+ * @param mode Whether to have the start, end, or both be smooth.
+ */
+ record PowerEasing(double exponent, Mode mode) implements EasingFunction {
+ /**
+ * Applies the power-curve easing to {@code t}.
+ *
+ * - IN: {@code t^exponent}
+ * - OUT: {@code 1 - (1-t)^exponent}
+ * - IN_OUT: piecewise symmetric combination of the above
+ *
+ *
+ * @param t normalised input in {@code [0, 1]}
+ * @return eased output value
+ */
+ @Override
+ public double apply(double t) {
+ return switch (mode) {
+ case IN -> Math.pow(t, exponent);
+ case OUT -> 1.0 - Math.pow(1.0 - t, exponent);
+ case IN_OUT -> {
+ if (t < 0.5)
+ yield Math.pow(2.0 * t, exponent) / 2.0;
+ yield 1.0 - Math.pow(-2.0 * t + 2.0, exponent) / 2.0;
+ }
+ };
+ }
+
+ /**
+ * @return a hash combining the exponent and mode ordinal,
+ * unique among all standard {@code PowerEasing} configurations
+ */
+ @Override
+ public int easingHash() {
+ return 31 * Double.hashCode(exponent) + mode.ordinal();
+ }
+ }
+
+ /**
+ * Sine-based easing.
+ * @param mode Whether to have the start, end, or both be smooth.
+ */
+ record SineEasing(Mode mode) implements EasingFunction {
+ /**
+ * Applies the sine-based easing to {@code t}.
+ *
+ * - IN: {@code 1 - cos(t * π/2)}
+ * - OUT: {@code sin(t * π/2)}
+ * - IN_OUT: {@code -(cos(π*t) - 1) / 2}
+ *
+ *
+ * @param t normalised input in {@code [0, 1]}
+ * @return eased output value
+ */
+ @Override
+ public double apply(double t) {
+ return switch (mode) {
+ case IN -> 1.0 - Math.cos(t * Math.PI / 2.0);
+ case OUT -> Math.sin(t * Math.PI / 2.0);
+ case IN_OUT -> -(Math.cos(Math.PI * t) - 1.0) / 2.0;
+ };
+ }
+
+ @Override
+ public int easingHash() {
+ return 1000 + mode.ordinal();
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Named constants
+ // -------------------------------------------------------------------------
+
+ EasingFunction LINEAR = new PowerEasing(1.0, Mode.IN);
+
+ EasingFunction QUAD_IN = new PowerEasing(2.0, Mode.IN);
+ EasingFunction QUAD_OUT = new PowerEasing(2.0, Mode.OUT);
+ EasingFunction QUAD_IN_OUT = new PowerEasing(2.0, Mode.IN_OUT);
+
+ EasingFunction CUBIC_IN = new PowerEasing(3.0, Mode.IN);
+ EasingFunction CUBIC_OUT = new PowerEasing(3.0, Mode.OUT);
+ EasingFunction CUBIC_IN_OUT = new PowerEasing(3.0, Mode.IN_OUT);
+
+ EasingFunction SINE_IN = new SineEasing(Mode.IN);
+ EasingFunction SINE_OUT = new SineEasing(Mode.OUT);
+ EasingFunction SINE_IN_OUT = new SineEasing(Mode.IN_OUT);
+
+ // -------------------------------------------------------------------------
+ // Factory methods
+ // -------------------------------------------------------------------------
+
+ /**
+ * @param exponent Degree to use for the easing function
+ * @return A power-based easing function with a gradual start and abrupt end
+ */
+ @Contract("_ -> new")
+ static @NotNull EasingFunction powerIn(double exponent) {
+ return new PowerEasing(exponent, Mode.IN);
+ }
+
+ /**
+ * @param exponent Degree to use for the easing function
+ * @return A power-based easing function with an abrupt start and gradual end
+ */
+ @Contract("_ -> new")
+ static @NotNull EasingFunction powerOut(double exponent) {
+ return new PowerEasing(exponent, Mode.OUT);
+ }
+
+ /**
+ * @param exponent Degree to use for the easing function
+ * @return A power-based easing function with a gradual start and end
+ */
+ @Contract("_ -> new")
+ static @NotNull EasingFunction powerInOut(double exponent) {
+ return new PowerEasing(exponent, Mode.IN_OUT);
+ }
+
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/HasInput.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/HasInput.java
new file mode 100644
index 0000000..4d158d1
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/HasInput.java
@@ -0,0 +1,17 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * A modifier that is driven by a {@link NormalizedInput}.
+ */
+public interface HasInput {
+
+ /**
+ * @return the input that drives this modifier
+ */
+ NormalizedInput getInput();
+
+ /**
+ * @param input the input to drive this modifier with
+ */
+ void setInput(NormalizedInput input);
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/NormalizedInput.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/NormalizedInput.java
new file mode 100644
index 0000000..80ad5ba
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/NormalizedInput.java
@@ -0,0 +1,23 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * A normalized input source for driving per-point modifier computations.
+ * Returns a value in [0, 1] derived from the point's current context.
+ */
+public interface NormalizedInput {
+
+ /**
+ * Returns a value in [0, 1] derived from the point's current context.
+ *
+ * @param point the current point context
+ * @return a value in [0, 1]
+ */
+ double sample(PointContext point);
+
+ /**
+ * Stable hash of this input's identity, for use in {@code modifierHash()}.
+ *
+ * @return a stable integer hash
+ */
+ int inputHash();
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointContext.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointContext.java
new file mode 100644
index 0000000..d3c0640
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointContext.java
@@ -0,0 +1,84 @@
+package com.sovdee.shapes.modifiers;
+
+import com.sovdee.shapes.shapes.Shape;
+
+/**
+ * Geometry-only context for a single point during modifier and render passes.
+ * A single instance is allocated per draw call and reused across all points.
+ *
+ * Clients that need render-specific properties (color, motion, visibility) should
+ * extend this class and override {@link #reset()} to clear their additional fields.
+ */
+public class PointContext {
+
+ /**
+ * Shape reference.
+ */
+ public Shape shape;
+
+ /**
+ * Local-space position (writable by geometry modifiers).
+ */
+ public double x, y, z;
+
+ /**
+ * 0-based index of this point in the draw order.
+ */
+ public int index;
+
+ /**
+ * Total number of points being drawn this pass.
+ */
+ public int totalPoints;
+
+ /**
+ * Pre-computed bounds for normalization helpers. Set once per pass before the modify loop.
+ */
+ public ShapeBounds bounds;
+
+ // ---- Normalization convenience (delegate to bounds) ----
+
+ /**
+ * @return Normalised X position within shape bounds [0, 1].
+ */
+ public double normalizedX() { return bounds.normalizeX(x); }
+
+ /**
+ * @return Normalised Y position within shape bounds [0, 1].
+ */
+ public double normalizedY() { return bounds.normalizeY(y); }
+
+ /**
+ * @return Normalised Z position within shape bounds [0, 1].
+ */
+ public double normalizedZ() { return bounds.normalizeZ(z); }
+
+ /**
+ * @return Normalised XZ radius [0, 1] relative to max XZ extent.
+ */
+ public double normalizedRadius() { return bounds.normalizeRadial(x, z); }
+
+ /**
+ * @return Normalised 3D spherical radius [0, 1] relative to max XZ radius.
+ */
+ public double normalizedSpherical() {
+ double r = Math.sqrt(x * x + y * y + z * z);
+ double maxR = bounds.maxRadiusXZ();
+ return maxR > 1e-12 ? r / maxR : 0.0;
+ }
+
+ /**
+ * @return XZ angle mapped to [0, 1] (0 = +X axis, wraps around).
+ */
+ public double angle() {
+ double a = Math.atan2(z, x);
+ if (a < 0) a += 2 * Math.PI;
+ return a / (2 * Math.PI);
+ }
+
+ /**
+ * Resets mutable (non-geometry) fields between points.
+ * Override in subclasses to clear render-specific properties (color, motion, etc.).
+ */
+ public void reset() {}
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointModifier.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointModifier.java
new file mode 100644
index 0000000..0bdecf1
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointModifier.java
@@ -0,0 +1,78 @@
+package com.sovdee.shapes.modifiers;
+
+import com.sovdee.shapes.sampling.DefaultPointSampler;
+
+/**
+ * Transforms individual points. Geometry modifiers (taper, twist, wave) transform position
+ * in local space before orientation/scale and are cached with shape state.
+ *
+ * Modifiers that can run prior to rendering (geometry-only modifiers) should extends {@link PreRenderPointModifier}
+ */
+public interface PointModifier extends Cloneable {
+
+ /**
+ * Called once per pass before the {@link #modify} loop.
+ * Pre-compute expensive state here (e.g., LUTs, inverse ranges).
+ *
+ * @param bounds the bounding box of the points for this pass
+ */
+ default void prepare(ShapeBounds bounds) {}
+
+ /**
+ * Called per point. Avoid allocations in this method.
+ * Modify the provided context to modify the point/render.
+ *
+ * @param point mutable per-point context, reused. Do not hold references.
+ */
+ void modify(Context point);
+
+ /**
+ * Returns a hash code representing this modifier's current configuration.
+ * Used for cache invalidation in {@link DefaultPointSampler}.
+ */
+ int modifierHash();
+
+ /**
+ * Returns a deep copy of this modifier. The clone must be fully independent:
+ * changes to the clone's configuration must not affect the original.
+ *
+ * @return a new modifier instance with the same configuration
+ */
+ PointModifier clone();
+
+ /**
+ * The minimum context type this modifier requires.
+ * Defaults to {@link PointContext} (compatible with any renderer).
+ * Render modifiers should override this to declare their specific required type,
+ * enabling validation in {@link DefaultPointSampler#render}.
+ */
+ default Class extends PointContext> contextType() {
+ return PointContext.class;
+ }
+
+ /**
+ * Marker base class for geometry-only (pre-render) modifiers.
+ * These run during point sampling and are cached with the shape state.
+ * They always operate on plain {@link PointContext}.
+ */
+ abstract class PreRenderPointModifier implements PointModifier {
+
+ /**
+ * Returns a shallow clone of this geometry modifier.
+ * Sufficient for modifiers whose fields are all primitives or immutable objects.
+ *
+ * @return a new {@code PointModifier} instance with the same configuration
+ * @throws RuntimeException wrapping {@link CloneNotSupportedException} if cloning fails
+ */
+ @Override
+ @SuppressWarnings("unchecked")
+ public PointModifier clone() {
+ try {
+ return (PointModifier) super.clone();
+ } catch (CloneNotSupportedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/RotationPlane.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/RotationPlane.java
new file mode 100644
index 0000000..811bdcf
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/RotationPlane.java
@@ -0,0 +1,21 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * The plane in which a {@link TwistModifier} rotates points.
+ */
+public enum RotationPlane {
+ /**
+ * rotates around the Y axis, acts on X and Z
+ */
+ XZ,
+
+ /**
+ * rotates around the Z axis, acts on X and Y
+ */
+ XY,
+
+ /**
+ * rotates around the X axis, acts on Y and Z
+ */
+ YZ
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScaleAxes.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScaleAxes.java
new file mode 100644
index 0000000..9639810
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScaleAxes.java
@@ -0,0 +1,54 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Specifies which coordinate axes are scaled by a {@link ScalingModifier}.
+ */
+public enum ScaleAxes {
+ /**
+ * Scales the X axis only.
+ */
+ X (true, false, false),
+
+ /**
+ * Scales the Y axis only.
+ */
+ Y (false, true, false),
+
+ /**
+ * Scales the Z axis only.
+ */
+ Z (false, false, true),
+
+ /**
+ * Scales the X and Y axes.
+ */
+ XY (true, true, false),
+
+ /**
+ * Scales the X and Z axes.
+ */
+ XZ (true, false, true),
+
+ /**
+ * Scales the Y and Z axes.
+ */
+ YZ (false, true, true),
+
+ /**
+ * Scales all three axes.
+ */
+ XYZ (true, true, true);
+
+ /** Whether the X axis is scaled. */
+ public final boolean x;
+ /** Whether the Y axis is scaled. */
+ public final boolean y;
+ /** Whether the Z axis is scaled. */
+ public final boolean z;
+
+ ScaleAxes(boolean x, boolean y, boolean z) {
+ this.x = x;
+ this.y = y;
+ this.z = z;
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScalingModifier.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScalingModifier.java
new file mode 100644
index 0000000..c31d8c4
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ScalingModifier.java
@@ -0,0 +1,148 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Scales any combination of axes by a value that linearly interpolates from
+ * {@code startScale} to {@code endScale} based on a {@link NormalizedInput}.
+ *
+ * Examples:
+ *
+ * - Taper a cylinder along Y: {@code new ScalingModifier(1, 0, StandardInput.Y, ScaleAxes.XZ)}
+ * - Taper inward from radial center: {@code new ScalingModifier(1, 0, StandardInput.RADIUS, ScaleAxes.XYZ)}
+ * - Scale by draw order: {@code new ScalingModifier(0, 1, StandardInput.T, ScaleAxes.XYZ)}
+ *
+ */
+public class ScalingModifier extends PointModifier.PreRenderPointModifier implements HasInput, Easable {
+
+ private double startScale;
+ private double endScale;
+ private NormalizedInput input;
+ private ScaleAxes axes;
+ private EasingFunction easing = EasingFunction.LINEAR;
+
+ /**
+ * Creates a {@code ScalingModifier} that scales the XZ plane driven by the Y axis.
+ *
+ * @param startScale scale factor at input minimum
+ * @param endScale scale factor at input maximum
+ */
+ public ScalingModifier(double startScale, double endScale) {
+ this(startScale, endScale, StandardInput.Y, ScaleAxes.XZ);
+ }
+
+ /**
+ * Creates a {@code ScalingModifier} with a specified input and set of scaled axes.
+ *
+ * @param startScale scale factor at input minimum
+ * @param endScale scale factor at input maximum
+ * @param input the normalized input that drives the scale factor
+ * @param axes which axes to scale
+ */
+ public ScalingModifier(double startScale, double endScale, NormalizedInput input, ScaleAxes axes) {
+ this.startScale = startScale;
+ this.endScale = endScale;
+ this.input = input;
+ this.axes = axes;
+ }
+
+ /**
+ * Scales the configured axes by {@code startScale + easing(t) * (endScale - startScale)},
+ * where {@code t} is sampled from the input.
+ *
+ * @param point the mutable point context to transform
+ */
+ @Override
+ public void modify(PointContext point) {
+ double t = easing.apply(input.sample(point));
+ double scale = startScale + t * (endScale - startScale);
+ if (axes.x) point.x *= scale;
+ if (axes.y) point.y *= scale;
+ if (axes.z) point.z *= scale;
+ }
+
+ /**
+ * {@inheritDoc}
+ * Includes {@code startScale}, {@code endScale}, {@code input}, {@code axes},
+ * and the easing hash.
+ */
+ @Override
+ public int modifierHash() {
+ int result = Double.hashCode(startScale);
+ result = 31 * result + Double.hashCode(endScale);
+ result = 31 * result + input.inputHash();
+ result = 31 * result + axes.ordinal();
+ result = 31 * result + easing.easingHash();
+ return result;
+ }
+
+ /**
+ * @return the scale factor at input minimum
+ */
+ public double getStartScale() {
+ return startScale;
+ }
+
+ /**
+ * @param startScale the scale factor at input minimum
+ */
+ public void setStartScale(double startScale) {
+ this.startScale = startScale;
+ }
+
+ /**
+ * @return the scale factor at input maximum
+ */
+ public double getEndScale() {
+ return endScale;
+ }
+
+ /**
+ * @param endScale the scale factor at input maximum
+ */
+ public void setEndScale(double endScale) {
+ this.endScale = endScale;
+ }
+
+ /**
+ * @return which axes are scaled
+ */
+ public ScaleAxes getAxes() {
+ return axes;
+ }
+
+ /**
+ * @param axes which axes to scale
+ */
+ public void setAxes(ScaleAxes axes) {
+ this.axes = axes;
+ }
+
+ @Override
+ public NormalizedInput getInput() {
+ return input;
+ }
+
+ @Override
+ public void setInput(NormalizedInput input) {
+ this.input = input;
+ }
+
+ @Override
+ public EasingFunction getEasing() {
+ return easing;
+ }
+
+ @Override
+ public void setEasing(EasingFunction easing) {
+ this.easing = easing;
+ }
+
+ /**
+ * Returns a deep copy of this modifier with independent state.
+ *
+ * @return a new {@code ScalingModifier} with the same configuration
+ */
+ @Override
+ public ScalingModifier clone() {
+ return (ScalingModifier) super.clone();
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ShapeBounds.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ShapeBounds.java
new file mode 100644
index 0000000..d658c69
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ShapeBounds.java
@@ -0,0 +1,190 @@
+package com.sovdee.shapes.modifiers;
+
+import org.joml.Vector3d;
+
+import java.util.List;
+
+/**
+ * Axis-aligned bounding box computed from a set of points.
+ * Provides normalization helpers for use in modifiers and shaders.
+ */
+public final class ShapeBounds {
+
+ private final double minX, minY, minZ;
+ private final double maxX, maxY, maxZ;
+ private final double rangeX, rangeY, rangeZ;
+ private final double maxRadius;
+
+ // Pre-computed inverse ranges for fast normalization (no division in hot loop)
+ private final double invRangeX, invRangeY, invRangeZ, invMaxRadius;
+
+ private ShapeBounds(double minX, double minY, double minZ,
+ double maxX, double maxY, double maxZ,
+ double maxRadius) {
+ this.minX = minX; this.minY = minY; this.minZ = minZ;
+ this.maxX = maxX; this.maxY = maxY; this.maxZ = maxZ;
+ this.rangeX = maxX - minX;
+ this.rangeY = maxY - minY;
+ this.rangeZ = maxZ - minZ;
+ this.maxRadius = maxRadius;
+ this.invRangeX = rangeX > 1e-6 ? 1.0 / rangeX : 0.0;
+ this.invRangeY = rangeY > 1e-6 ? 1.0 / rangeY : 0.0;
+ this.invRangeZ = rangeZ > 1e-6 ? 1.0 / rangeZ : 0.0;
+ this.invMaxRadius = maxRadius > 1e-6 ? 1.0 / maxRadius : 0.0;
+ }
+
+ /**
+ * Computes the bounding box from the given list of points.
+ * Returns a degenerate (all-zero) bounds if the list is empty.
+ */
+ public static ShapeBounds compute(List points) {
+ if (points.isEmpty()) {
+ return new ShapeBounds(0, 0, 0, 0, 0, 0, 0);
+ }
+ double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE, minZ = Double.MAX_VALUE;
+ double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE, maxZ = -Double.MAX_VALUE;
+ double maxRadius = 0;
+ for (Vector3d p : points) {
+ if (p.x < minX) minX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.z < minZ) minZ = p.z;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y > maxY) maxY = p.y;
+ if (p.z > maxZ) maxZ = p.z;
+ double r = Math.sqrt(p.x * p.x + p.z * p.z);
+ if (r > maxRadius) maxRadius = r;
+ }
+ return new ShapeBounds(minX, minY, minZ, maxX, maxY, maxZ, maxRadius);
+ }
+
+ /**
+ * Normalizes x to [0, 1] within the shape's X extent.
+ */
+ public double normalizeX(double x) {
+ return (x - minX) * invRangeX;
+ }
+
+ /**
+ * Normalizes y to [0, 1] within the shape's Y extent.
+ */
+ public double normalizeY(double y) {
+ return (y - minY) * invRangeY;
+ }
+
+ /**
+ * Normalizes z to [0, 1] within the shape's Z extent.
+ */
+ public double normalizeZ(double z) {
+ return (z - minZ) * invRangeZ;
+ }
+
+ /**
+ * Normalizes the XZ radius to [0, 1] relative to the shape's max XZ radius.
+ */
+ public double normalizeRadial(double x, double z) {
+ return Math.sqrt(x * x + z * z) * invMaxRadius;
+ }
+
+ /**
+ * Returns the max XZ distance from origin across all points.
+ */
+ public double maxRadiusXZ() {
+ return maxRadius;
+ }
+
+ /**
+ * @return the extent of the bounding box along the Y axis ({@code maxY - minY})
+ */
+ public double height() {
+ return rangeY;
+ }
+ /**
+ * @return the extent of the bounding box along the X axis ({@code maxX - minX})
+ */
+ public double width() {
+ return rangeX;
+ }
+ /**
+ * @return the extent of the bounding box along the Z axis ({@code maxZ - minZ})
+ */
+ public double length() {
+ return rangeZ;
+ }
+
+ /**
+ * @return the minimum X coordinate across all points
+ */
+ public double minX() {
+ return minX;
+ }
+ /**
+ * @return the minimum Y coordinate across all points
+ */
+ public double minY() {
+ return minY;
+ }
+ /**
+ * @return the minimum Z coordinate across all points
+ */
+ public double minZ() {
+ return minZ;
+ }
+ /**
+ * @return the maximum X coordinate across all points
+ */
+ public double maxX() {
+ return maxX;
+ }
+ /**
+ * @return the maximum Y coordinate across all points
+ */
+ public double maxY() {
+ return maxY;
+ }
+ /**
+ * @return the maximum Z coordinate across all points
+ */
+ public double maxZ() {
+ return maxZ;
+ }
+
+ /**
+ * Pre-computed reciprocal of the X extent, used for fast normalisation without division.
+ * Returns {@code 0} if the X extent is degenerate (less than {@code 1e-12}).
+ *
+ * @return {@code 1 / rangeX}, or {@code 0} if the range is effectively zero
+ */
+ public double invRangeX() {
+ return invRangeX;
+ }
+
+ /**
+ * Pre-computed reciprocal of the Y extent, used for fast normalisation without division.
+ * Returns {@code 0} if the Y extent is degenerate (less than {@code 1e-12}).
+ *
+ * @return {@code 1 / rangeY}, or {@code 0} if the range is effectively zero
+ */
+ public double invRangeY() {
+ return invRangeY;
+ }
+
+ /**
+ * Pre-computed reciprocal of the Z extent, used for fast normalisation without division.
+ * Returns {@code 0} if the Z extent is degenerate (less than {@code 1e-12}).
+ *
+ * @return {@code 1 / rangeZ}, or {@code 0} if the range is effectively zero
+ */
+ public double invRangeZ() {
+ return invRangeZ;
+ }
+
+ /**
+ * Pre-computed reciprocal of the maximum XZ radius, used for fast radial normalisation.
+ * Returns {@code 0} if the maximum radius is degenerate (less than {@code 1e-12}).
+ *
+ * @return {@code 1 / maxRadius}, or {@code 0} if the radius is effectively zero
+ */
+ public double invMaxRadius() {
+ return invMaxRadius;
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/SpatialAxis.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/SpatialAxis.java
new file mode 100644
index 0000000..b5f72bc
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/SpatialAxis.java
@@ -0,0 +1,22 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * A spatial coordinate axis.
+ * Used to specify the output displacement axis of a {@link WaveModifier}.
+ */
+public enum SpatialAxis {
+ /**
+ * The X axis.
+ */
+ X,
+
+ /**
+ * The Y axis.
+ */
+ Y,
+
+ /**
+ * The Z axis.
+ */
+ Z
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/StandardInput.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/StandardInput.java
new file mode 100644
index 0000000..f53bf6e
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/StandardInput.java
@@ -0,0 +1,71 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Standard normalized inputs for driving geometry modifiers.
+ * Each constant samples a specific aspect of the point's context,
+ * returning a value in [0, 1].
+ */
+public enum StandardInput implements NormalizedInput {
+ /**
+ * Normalized position within the X extent of the shape's bounds.
+ */
+ X {
+ @Override
+ public double sample(PointContext point) { return point.normalizedX(); }
+ },
+
+ /**
+ * Normalized position within the Y extent of the shape's bounds.
+ */
+ Y {
+ @Override
+ public double sample(PointContext point) { return point.normalizedY(); }
+ },
+
+ /**
+ * Normalized position within the Z extent of the shape's bounds.
+ */
+ Z {
+ @Override
+ public double sample(PointContext point) { return point.normalizedZ(); }
+ },
+
+ /**
+ * Normalized draw order: {@code index / totalPoints}.
+ */
+ T {
+ @Override
+ public double sample(PointContext point) {
+ return point.totalPoints > 0 ? (double) point.index / point.totalPoints : 0.0;
+ }
+ },
+
+ /**
+ * XZ distance from the origin, normalized to the maximum XZ radius.
+ */
+ RADIUS {
+ @Override
+ public double sample(PointContext point) { return point.normalizedRadius(); }
+ },
+
+ /**
+ * 3D distance from the origin, normalized to the maximum XZ radius.
+ */
+ SPHERICAL {
+ @Override
+ public double sample(PointContext point) { return point.normalizedSpherical(); }
+ },
+
+ /**
+ * XZ angle mapped to [0, 1] over a full revolution (0 = +X axis).
+ */
+ ANGLE {
+ @Override
+ public double sample(PointContext point) { return point.angle(); }
+ };
+
+ @Override
+ public int inputHash() {
+ return ordinal();
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/TwistModifier.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/TwistModifier.java
new file mode 100644
index 0000000..df3d75e
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/TwistModifier.java
@@ -0,0 +1,147 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Rotates each point in the specified {@link RotationPlane} by an angle proportional to
+ * the value of a {@link NormalizedInput}. A {@code totalAngle} of 2π produces one full twist.
+ *
+ * Examples:
+ *
+ * - Classic Y-axis twist: {@code new TwistModifier(2*PI, StandardInput.Y, RotationPlane.XZ)}
+ * - Radial tornado: {@code new TwistModifier(2*PI, StandardInput.RADIUS, RotationPlane.XZ)}
+ * - Draw-order twist: {@code new TwistModifier(PI, StandardInput.T, RotationPlane.XZ)}
+ *
+ */
+public class TwistModifier extends PointModifier.PreRenderPointModifier implements HasInput, Easable {
+
+ private double totalAngle;
+ private NormalizedInput input;
+ private RotationPlane plane;
+ private EasingFunction easing = EasingFunction.LINEAR;
+
+ /**
+ * Creates a {@code TwistModifier} in the XZ plane driven by the Y axis.
+ *
+ * @param totalAngle total rotation in radians; {@code 2π} produces one full revolution
+ */
+ public TwistModifier(double totalAngle) {
+ this(totalAngle, StandardInput.Y, RotationPlane.XZ);
+ }
+
+ /**
+ * Creates a {@code TwistModifier} with a specified input and rotation plane.
+ *
+ * @param totalAngle total rotation in radians; {@code 2π} produces one full revolution
+ * @param input the normalized input that drives the rotation angle
+ * @param plane the plane in which points are rotated
+ */
+ public TwistModifier(double totalAngle, NormalizedInput input, RotationPlane plane) {
+ this.totalAngle = totalAngle;
+ this.input = input;
+ this.plane = plane;
+ }
+
+ /**
+ * Rotates the point in the configured plane by {@code easing(t) * totalAngle} radians,
+ * where {@code t} is sampled from the input.
+ *
+ * @param point the mutable point context to transform
+ */
+ @Override
+ public void modify(PointContext point) {
+ double t = easing.apply(input.sample(point));
+ double angle = totalAngle * t;
+ double cos = Math.cos(angle);
+ double sin = Math.sin(angle);
+ switch (plane) {
+ case YZ -> {
+ double newY = point.y * cos - point.z * sin;
+ double newZ = point.y * sin + point.z * cos;
+ point.y = newY;
+ point.z = newZ;
+ }
+ case XY -> {
+ double newX = point.x * cos - point.y * sin;
+ double newY = point.x * sin + point.y * cos;
+ point.x = newX;
+ point.y = newY;
+ }
+ default -> { // XZ
+ double newX = point.x * cos - point.z * sin;
+ double newZ = point.x * sin + point.z * cos;
+ point.x = newX;
+ point.z = newZ;
+ }
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * Includes {@code totalAngle}, {@code input}, {@code plane}, and the easing hash.
+ */
+ @Override
+ public int modifierHash() {
+ int result = Double.hashCode(totalAngle);
+ result = 31 * result + input.inputHash();
+ result = 31 * result + plane.ordinal();
+ result = 31 * result + easing.easingHash();
+ return result;
+ }
+
+ /**
+ * @return the total rotation angle in radians
+ */
+ public double getTotalAngle() {
+ return totalAngle;
+ }
+
+ /**
+ * @param totalAngle the total rotation angle in radians
+ */
+ public void setTotalAngle(double totalAngle) {
+ this.totalAngle = totalAngle;
+ }
+
+ /**
+ * @return the rotation plane
+ */
+ public RotationPlane getPlane() {
+ return plane;
+ }
+
+ /**
+ * @param plane the rotation plane
+ */
+ public void setPlane(RotationPlane plane) {
+ this.plane = plane;
+ }
+
+ @Override
+ public NormalizedInput getInput() {
+ return input;
+ }
+
+ @Override
+ public void setInput(NormalizedInput input) {
+ this.input = input;
+ }
+
+ @Override
+ public EasingFunction getEasing() {
+ return easing;
+ }
+
+ @Override
+ public void setEasing(EasingFunction easing) {
+ this.easing = easing;
+ }
+
+ /**
+ * Returns a deep copy of this modifier with independent state.
+ *
+ * @return a new {@code TwistModifier} with the same configuration
+ */
+ @Override
+ public TwistModifier clone() {
+ return (TwistModifier) super.clone();
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/WaveModifier.java b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/WaveModifier.java
new file mode 100644
index 0000000..d9be7ce
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/WaveModifier.java
@@ -0,0 +1,163 @@
+package com.sovdee.shapes.modifiers;
+
+/**
+ * Displaces points by {@code amplitude * sin(2π * frequency * input + phase)},
+ * where input and output are independently configurable.
+ *
+ * {@code frequency} is expressed in cycles over the full [0, 1] input range,
+ * so {@code frequency=1} always means exactly one full sine cycle regardless of
+ * shape size or which input is used.
+ *
+ * Output axis: the coordinate component to displace (X, Y, or Z).
+ * Input: any {@link NormalizedInput} — X, Y, Z, T, RADIUS, SPHERICAL, or ANGLE.
+ */
+public class WaveModifier extends PointModifier.PreRenderPointModifier {
+
+ private double amplitude;
+ private double frequency;
+ private double phase;
+ private NormalizedInput input;
+ private SpatialAxis outputAxis;
+
+ /**
+ * Creates a fully configured wave modifier.
+ *
+ * @param amplitude peak displacement distance (positive or negative)
+ * @param frequency cycles over the full [0, 1] input range; {@code 1} = one full cycle
+ * @param phase phase offset in radians
+ * @param input the normalized input used as the sine function's argument
+ * @param outputAxis the spatial coordinate component to displace
+ */
+ public WaveModifier(double amplitude, double frequency, double phase,
+ NormalizedInput input, SpatialAxis outputAxis) {
+ this.amplitude = amplitude;
+ this.frequency = frequency;
+ this.phase = phase;
+ this.input = input;
+ this.outputAxis = outputAxis;
+ }
+
+ /**
+ * Creates a wave displacing {@code outputAxis} driven by normalized draw order (T),
+ * with a phase of {@code 0}.
+ *
+ * @param amplitude peak displacement distance
+ * @param frequency cycles over the full [0, 1] T range
+ * @param outputAxis the spatial coordinate component to displace
+ */
+ public WaveModifier(double amplitude, double frequency, SpatialAxis outputAxis) {
+ this(amplitude, frequency, 0.0, StandardInput.T, outputAxis);
+ }
+
+ /**
+ * Displaces the point along {@code outputAxis} by
+ * {@code amplitude * sin(2π * frequency * input + phase)},
+ * where {@code input} is sampled from the configured {@link NormalizedInput}.
+ *
+ * @param point the mutable point context to transform
+ */
+ @Override
+ public void modify(PointContext point) {
+ double displacement = amplitude * Math.sin(2 * Math.PI * frequency * input.sample(point) + phase);
+ switch (outputAxis) {
+ case X -> point.x += displacement;
+ case Y -> point.y += displacement;
+ case Z -> point.z += displacement;
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ * Includes {@code amplitude}, {@code frequency}, {@code phase}, {@code input},
+ * and {@code outputAxis}.
+ */
+ @Override
+ public int modifierHash() {
+ int result = Double.hashCode(amplitude);
+ result = 31 * result + Double.hashCode(frequency);
+ result = 31 * result + Double.hashCode(phase);
+ result = 31 * result + input.inputHash();
+ result = 31 * result + outputAxis.ordinal();
+ return result;
+ }
+
+ /**
+ * @return the peak displacement distance
+ */
+ public double getAmplitude() {
+ return amplitude;
+ }
+
+ /**
+ * @param amplitude the peak displacement distance
+ */
+ public void setAmplitude(double amplitude) {
+ this.amplitude = amplitude;
+ }
+
+ /**
+ * @return the number of cycles over the full [0, 1] input range
+ */
+ public double getFrequency() {
+ return frequency;
+ }
+
+ /**
+ * @param frequency the number of cycles over the full [0, 1] input range
+ */
+ public void setFrequency(double frequency) {
+ this.frequency = frequency;
+ }
+
+ /**
+ * @return the phase offset in radians
+ */
+ public double getPhase() {
+ return phase;
+ }
+
+ /**
+ * @param phase the phase offset in radians
+ */
+ public void setPhase(double phase) {
+ this.phase = phase;
+ }
+
+ /**
+ * @return the normalized input driving the wave
+ */
+ public NormalizedInput getInput() {
+ return input;
+ }
+
+ /**
+ * @param input the normalized input to drive the wave with
+ */
+ public void setInput(NormalizedInput input) {
+ this.input = input;
+ }
+
+ /**
+ * @return the spatial coordinate component that is displaced
+ */
+ public SpatialAxis getOutputAxis() {
+ return outputAxis;
+ }
+
+ /**
+ * @param outputAxis the spatial coordinate component to displace
+ */
+ public void setOutputAxis(SpatialAxis outputAxis) {
+ this.outputAxis = outputAxis;
+ }
+
+ /**
+ * Returns a deep copy of this modifier with independent state.
+ *
+ * @return a new {@code WaveModifier} with the same configuration
+ */
+ @Override
+ public WaveModifier clone() {
+ return (WaveModifier) super.clone();
+ }
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DefaultPointSampler.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DefaultPointSampler.java
index cd76bac..3272f4e 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DefaultPointSampler.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DefaultPointSampler.java
@@ -1,13 +1,16 @@
package com.sovdee.shapes.sampling;
+import com.sovdee.shapes.modifiers.PointContext;
+import com.sovdee.shapes.modifiers.PointModifier;
+import com.sovdee.shapes.modifiers.PointModifier.PreRenderPointModifier;
+import com.sovdee.shapes.modifiers.ShapeBounds;
import com.sovdee.shapes.shapes.Shape;
import org.joml.Quaterniond;
import org.joml.Vector3d;
+import java.util.ArrayList;
import java.util.Comparator;
-import java.util.LinkedHashSet;
-import java.util.Set;
-import java.util.TreeSet;
+import java.util.List;
import java.util.UUID;
/**
@@ -15,47 +18,116 @@
*/
public class DefaultPointSampler implements PointSampler {
+ private static final int DEFAULT_MAX_POINTS = 10_000;
+
private SamplingStyle style = SamplingStyle.OUTLINE;
private double density = 0.25;
+ private int maxPoints = DEFAULT_MAX_POINTS;
+ private boolean densityExplicit = false;
private Comparator ordering;
private final UUID uuid;
private DrawContext drawContext;
+ private List> modifiers = new ArrayList<>();
// Cache
- private Set cachedPoints = new LinkedHashSet<>();
+ private List cachedPoints = new ArrayList<>();
private CacheState lastState;
private boolean needsUpdate = false;
+ /**
+ * Creates a new {@code DefaultPointSampler} with default settings:
+ * {@link SamplingStyle#OUTLINE}, density {@code 0.25}, max points {@code 10 000},
+ * no ordering, no modifiers, and an empty point cache.
+ * A fresh {@link UUID} is assigned at construction time.
+ */
public DefaultPointSampler() {
this.uuid = UUID.randomUUID();
- this.lastState = new CacheState(style, 0, 1.0, 0, density, 0);
+ this.lastState = new CacheState(style, 0, 1.0, 0, density, 0, 0);
}
+
+ /**
+ * Returns sampled points for the given shape using the shape's own orientation.
+ * Results are cached and only recomputed when the shape or sampler state changes.
+ *
+ * @param shape the shape to sample
+ * @return an ordered list of transformed world-space points
+ */
@Override
- public Set getPoints(Shape shape) {
+ public List getPoints(Shape shape) {
return getPoints(shape, shape.getOrientation());
}
+ /**
+ * Returns sampled points for the given shape using the supplied orientation quaternion.
+ * The cache is invalidated when the style, orientation, scale, offset, density,
+ * shape version, or geometry-modifier configuration changes.
+ * Points are transformed (orientation → scale → offset) before being returned.
+ *
+ * @param shape the shape to sample
+ * @param orientation the orientation to apply to the sampled points
+ * @return an ordered list of transformed world-space points
+ */
@Override
- public Set getPoints(Shape shape, Quaterniond orientation) {
+ public List getPoints(Shape shape, Quaterniond orientation) {
+ double workingDensity;
+ if (densityExplicit) {
+ workingDensity = density;
+ } else {
+ double auto = shape.computeDensity(style, maxPoints);
+ workingDensity = (auto > Shape.EPSILON) ? auto : density;
+ }
+
CacheState state = new CacheState(style, orientation.hashCode(),
- shape.getScale(), shape.getOffset().hashCode(), density, shape.getVersion());
+ shape.getScale(), shape.getOffset().hashCode(), workingDensity, shape.getVersion(),
+ computeModifierHash());
if (shape.isDynamic() || needsUpdate || !state.equals(lastState) || cachedPoints.isEmpty()) {
- Set points = (ordering != null) ? new TreeSet<>(ordering) : new LinkedHashSet<>();
+ List points = new ArrayList<>();
- shape.beforeSampling(density);
+ shape.beforeSampling(workingDensity);
switch (style) {
- case OUTLINE -> shape.generateOutline(points, density);
- case SURFACE -> shape.generateSurface(points, density);
- case FILL -> shape.generateFilled(points, density);
+ case OUTLINE -> shape.generateOutline(points, workingDensity);
+ case SURFACE -> shape.generateSurface(points, workingDensity);
+ case FILL -> shape.generateFilled(points, workingDensity);
}
shape.afterSampling(points);
+ // Apply geometry modifiers per-point
+ List> geoMods = new ArrayList<>();
+ for (PointModifier> mod : modifiers) {
+ if (mod instanceof PreRenderPointModifier) //noinspection unchecked
+ geoMods.add((PointModifier) mod);
+ }
+ if (!geoMods.isEmpty()) {
+ ShapeBounds bounds = ShapeBounds.compute(points);
+ PointContext ctx = new PointContext();
+ ctx.shape = shape;
+ ctx.bounds = bounds;
+ ctx.totalPoints = points.size();
+ for (PointModifier mod : geoMods) {
+ mod.prepare(bounds);
+ }
+ for (int i = 0; i < points.size(); i++) {
+ Vector3d p = points.get(i);
+ ctx.x = p.x; ctx.y = p.y; ctx.z = p.z;
+ ctx.index = i;
+ for (PointModifier mod : geoMods) {
+ mod.modify(ctx);
+ }
+ p.x = ctx.x; p.y = ctx.y; p.z = ctx.z;
+ }
+ }
+
for (Vector3d point : points) {
orientation.transform(point);
point.mul(shape.getScale());
point.add(shape.getOffset());
}
+
+ if (ordering != null) {
+ points.sort(ordering);
+ }
+
cachedPoints = points;
lastState = state;
needsUpdate = false;
@@ -63,55 +135,274 @@ public Set getPoints(Shape shape, Quaterniond orientation) {
return cachedPoints;
}
+ /**
+ * Drives the render loop for a shape. Gets cached points, prepares render modifiers,
+ * then calls the renderer for each point.
+ *
+ * @param shape the shape to render
+ * @param orientation the orientation to use for point sampling
+ * @param renderer the client-provided renderer
+ */
+ @Override
+ public void render(Shape shape, Quaterniond orientation, ShapeRenderer renderer) {
+ List points = getPoints(shape, orientation);
+ Context context = renderer.getContext();
+
+ // Collect render modifiers (everything that is not a pre-render/geometry modifier)
+ List> renderMods = new ArrayList<>();
+ Class> ctxClass = context.getClass();
+ for (PointModifier> mod : modifiers) {
+ if (mod instanceof PreRenderPointModifier) continue;
+ if (!mod.contextType().isAssignableFrom(ctxClass)) {
+ throw new IllegalStateException(
+ "Render modifier " + mod.getClass().getSimpleName() +
+ " requires context type " + mod.contextType().getSimpleName() +
+ " but renderer provides " + ctxClass.getSimpleName()
+ );
+ }
+ //noinspection unchecked
+ renderMods.add((PointModifier) mod);
+ }
+
+ // Prepare render modifiers
+ ShapeBounds bounds = ShapeBounds.compute(points);
+ context.bounds = bounds;
+ context.totalPoints = points.size();
+ context.shape = shape;
+ for (PointModifier mod : renderMods) {
+ mod.prepare(bounds);
+ }
+
+ // Render loop
+ renderer.begin(points.size());
+ for (int i = 0; i < points.size(); i++) {
+ Vector3d p = points.get(i);
+ context.reset();
+ context.x = p.x; context.y = p.y; context.z = p.z;
+ context.index = i;
+ for (PointModifier mod : renderMods) {
+ mod.modify(context);
+ }
+ renderer.renderPoint(context);
+ }
+ renderer.end();
+ }
+
+ /**
+ * Forces the point cache to be regenerated on the next call to
+ * {@link #getPoints(Shape)} or {@link #getPoints(Shape, Quaterniond)}.
+ * Useful when an external change affects the shape that is not tracked by the cache key.
+ */
public void markDirty() {
needsUpdate = true;
}
+ /**
+ * @return the current sampling style (outline, surface, or fill)
+ */
@Override
- public SamplingStyle getStyle() { return style; }
+ public SamplingStyle getStyle() {
+ return style;
+ }
+ /**
+ * Sets the sampling style and invalidates the point cache.
+ *
+ * @param style the new sampling style
+ */
@Override
public void setStyle(SamplingStyle style) {
this.style = style;
this.needsUpdate = true;
}
+ /**
+ * Returns the current point spacing density.
+ * Only meaningful when density has been set explicitly via {@link #setDensity};
+ * otherwise the value is overridden by the auto-computed density from {@link #setMaxPoints}.
+ *
+ * @return the current density value
+ */
@Override
- public double getDensity() { return density; }
+ public double getDensity() {
+ return density;
+ }
+ /**
+ * Sets the point spacing density explicitly, overriding any max-points-based auto-density.
+ * Clamps the value to at least {@code Shape.EPSILON}. Invalidates the point cache.
+ *
+ * @param density the desired point spacing; smaller values produce more points
+ */
@Override
public void setDensity(double density) {
this.density = Math.max(density, Shape.EPSILON);
+ this.densityExplicit = true;
+ this.needsUpdate = true;
+ }
+
+ /**
+ * @return the maximum number of points that auto-density calculation will target
+ */
+ @Override
+ public int getMaxPoints() {
+ return maxPoints;
+ }
+
+ /**
+ * Sets the target maximum number of points and switches to auto-density mode,
+ * where density is derived from the shape geometry to approximate this count.
+ * Clamps the value to at least {@code 1}. Invalidates the point cache.
+ *
+ * @param maxPoints the target maximum particle count
+ */
+ @Override
+ public void setMaxPoints(int maxPoints) {
+ this.maxPoints = Math.max(1, maxPoints);
+ this.densityExplicit = false;
this.needsUpdate = true;
}
+ /**
+ * Returns whether density was set explicitly via {@link #setDensity}.
+ * When {@code false}, density is auto-computed from the shape to target {@link #getMaxPoints()}.
+ *
+ * @return {@code true} if density was set explicitly
+ */
@Override
- public Comparator getOrdering() { return ordering; }
+ public boolean isDensityExplicit() {
+ return densityExplicit;
+ }
+
+ /**
+ * @return the comparator used to sort sampled points, or {@code null} for insertion order
+ */
+ @Override
+ public Comparator getOrdering() {
+ return ordering;
+ }
+ /**
+ * Sets the comparator used to sort sampled points after generation.
+ * Pass {@code null} to disable sorting. Invalidates the point cache.
+ *
+ * @param ordering a comparator over {@link org.joml.Vector3d}, or {@code null}
+ */
@Override
public void setOrdering(Comparator ordering) {
this.ordering = ordering;
this.needsUpdate = true;
}
+ /**
+ * Returns the unique identifier for this sampler instance.
+ * The UUID is generated at construction time and never changes.
+ *
+ * @return the immutable UUID of this sampler
+ */
@Override
- public UUID getUUID() { return uuid; }
+ public UUID getUUID() {
+ return uuid;
+ }
+ /**
+ * @return the client-provided {@link DrawContext}, or {@code null} if none is set
+ */
@Override
- public DrawContext getDrawContext() { return drawContext; }
+ public DrawContext getDrawContext() {
+ return drawContext;
+ }
+ /**
+ * Sets the client-provided rendering context.
+ *
+ * @param context the {@link DrawContext} to associate with this sampler
+ */
@Override
public void setDrawContext(DrawContext context) { this.drawContext = context; }
+ /**
+ * Returns the live modifier list. Callers should prefer {@link #addModifier},
+ * {@link #removeModifier}, and {@link #clearModifiers} to ensure cache invalidation.
+ *
+ * @return the mutable list of registered modifiers
+ */
+ @Override
+ public List> getModifiers() {
+ return modifiers;
+ }
+
+ /**
+ * Appends a modifier to the end of the modifier list.
+ * Geometry modifiers ({@link PointModifier.PreRenderPointModifier}) additionally
+ * invalidate the point cache.
+ *
+ * @param modifier the modifier to add
+ */
+ @Override
+ public void addModifier(PointModifier> modifier) {
+ modifiers.add(modifier);
+ if (modifier instanceof PreRenderPointModifier) needsUpdate = true;
+ }
+
+ /**
+ * Removes a modifier from the list if present.
+ * If the removed modifier is a geometry modifier, the point cache is invalidated.
+ *
+ * @param modifier the modifier to remove
+ */
+ @Override
+ public void removeModifier(PointModifier> modifier) {
+ if (modifiers.remove(modifier)) {
+ if (modifier instanceof PreRenderPointModifier) needsUpdate = true;
+ }
+ }
+
+ /**
+ * Removes all modifiers. If any geometry modifier was registered,
+ * the point cache is invalidated.
+ */
+ @Override
+ public void clearModifiers() {
+ if (!modifiers.isEmpty()) {
+ // If any geometry modifier was present, invalidate cache
+ boolean hadGeo = modifiers.stream().anyMatch(m -> m instanceof PreRenderPointModifier);
+ modifiers.clear();
+ if (hadGeo) needsUpdate = true;
+ }
+ }
+
+ private int computeModifierHash() {
+ int hash = 1;
+ for (PointModifier> mod : modifiers) {
+ if (mod instanceof PreRenderPointModifier) hash = 31 * hash + mod.modifierHash();
+ }
+ return hash;
+ }
+
+ /**
+ * Returns a deep copy of this sampler. The clone has an empty point cache
+ * (forcing a fresh sample on first use) but otherwise copies all configuration,
+ * including a deep-copied modifier list and a copied {@link DrawContext}.
+ * The UUID is not shared; the clone inherits the same UUID value.
+ *
+ * @return a new {@code DefaultPointSampler} with the same configuration
+ */
@Override
public DefaultPointSampler clone() {
try {
DefaultPointSampler copy = (DefaultPointSampler) super.clone();
// Don't share the cache
- copy.cachedPoints = new LinkedHashSet<>();
+ copy.cachedPoints = new ArrayList<>();
copy.needsUpdate = true;
+ // maxPoints and densityExplicit are primitives — copied by super.clone()
if (drawContext != null)
copy.drawContext = drawContext.copy();
+ // Deep-copy modifiers
+ copy.modifiers = new ArrayList<>(modifiers.size());
+ for (PointModifier> mod : modifiers) {
+ copy.modifiers.add(mod.clone());
+ }
return copy;
} catch (CloneNotSupportedException e) {
throw new AssertionError(e);
@@ -119,5 +410,6 @@ public DefaultPointSampler clone() {
}
private record CacheState(SamplingStyle style, int orientationHash, double scale,
- int offsetHash, double density, long shapeVersion) {}
+ int offsetHash, double density, long shapeVersion,
+ int modifierHash) {}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DrawContext.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DrawContext.java
index 19427f0..e20cda8 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DrawContext.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/DrawContext.java
@@ -4,5 +4,12 @@
* Client-provided rendering metadata. Implementations are opaque to the library.
*/
public interface DrawContext {
+ /**
+ * Returns an independent copy of this context.
+ * Used by {@link DefaultPointSampler#clone()} to ensure the cloned sampler
+ * does not share rendering state with the original.
+ *
+ * @return a new {@code DrawContext} with the same configuration
+ */
DrawContext copy();
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointSampler.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointSampler.java
index 1d7221c..69b76e0 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointSampler.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointSampler.java
@@ -1,11 +1,13 @@
package com.sovdee.shapes.sampling;
+import com.sovdee.shapes.modifiers.PointContext;
+import com.sovdee.shapes.modifiers.PointModifier;
import com.sovdee.shapes.shapes.Shape;
import org.joml.Quaterniond;
import org.joml.Vector3d;
import java.util.Comparator;
-import java.util.Set;
+import java.util.List;
import java.util.UUID;
/**
@@ -14,29 +16,95 @@
*/
public interface PointSampler extends Cloneable {
+ /**
+ * @return the current {@link SamplingStyle} (outline, surface, or fill)
+ */
SamplingStyle getStyle();
+
+ /**
+ * Sets the style used when sampling the shape's geometry.
+ *
+ * @param style the new sampling style
+ */
void setStyle(SamplingStyle style);
+ /**
+ * Returns the explicit point spacing density, if one has been set.
+ * When density is not explicit, implementations derive it from {@link #getMaxPoints()}.
+ *
+ * @return the current density value
+ */
double getDensity();
+
+ /**
+ * Sets an explicit point spacing density.
+ * Smaller values produce more points; calling this switches the sampler to explicit-density mode.
+ *
+ * @param density the desired point spacing
+ */
void setDensity(double density);
+ /**
+ * @return the target maximum number of points used in auto-density mode
+ */
+ int getMaxPoints();
+
+ /**
+ * Sets the target maximum number of points and enables auto-density mode,
+ * where the implementation derives an appropriate density from the shape geometry.
+ *
+ * @param maxPoints the target maximum particle count
+ */
+ void setMaxPoints(int maxPoints);
+
+ /**
+ * Returns whether density was set explicitly via {@link #setDensity}.
+ * When {@code false}, density is auto-computed from the shape to target {@link #getMaxPoints()}.
+ *
+ * @return {@code true} if density is explicit
+ */
+ boolean isDensityExplicit();
+
+ /**
+ * @return the comparator used to sort sampled points, or {@code null} for insertion order
+ */
Comparator getOrdering();
+
+ /**
+ * Sets the comparator used to sort sampled points after generation.
+ *
+ * @param ordering a comparator over {@link Vector3d}, or {@code null} to disable sorting
+ */
void setOrdering(Comparator ordering);
+ /**
+ * Returns the unique identifier assigned to this sampler at construction time.
+ *
+ * @return the immutable UUID of this sampler
+ */
UUID getUUID();
+ /**
+ * @return the client-provided {@link DrawContext}, or {@code null} if none is set
+ */
DrawContext getDrawContext();
+
+ /**
+ * Associates a client-provided rendering context with this sampler.
+ *
+ * @param context the {@link DrawContext} to use during rendering
+ */
void setDrawContext(DrawContext context);
/**
* Samples points from the given shape using the shape's own orientation.
*/
- Set getPoints(Shape shape);
+ List getPoints(Shape shape);
/**
* Samples points from the given shape using the given orientation.
*/
- Set getPoints(Shape shape, Quaterniond orientation);
+ List getPoints(Shape shape, Quaterniond orientation);
/**
* Computes and sets the density to achieve approximately the given particle count.
@@ -45,5 +113,52 @@ default void setParticleCount(Shape shape, int count) {
setDensity(shape.computeDensity(getStyle(), count));
}
+ // ---- Modifier management ----
+
+ /**
+ * Returns the list of registered {@link PointModifier}s.
+ * Geometry modifiers are applied during point sampling; render modifiers are applied per frame.
+ *
+ * @return the mutable modifier list
+ */
+ List> getModifiers();
+
+ /**
+ * Appends a modifier to the modifier list.
+ * Geometry modifiers may additionally invalidate the point cache.
+ *
+ * @param modifier the modifier to add
+ */
+ void addModifier(PointModifier> modifier);
+
+ /**
+ * Removes a modifier from the list if present.
+ * Geometry modifiers may additionally invalidate the point cache.
+ *
+ * @param modifier the modifier to remove
+ */
+ void removeModifier(PointModifier> modifier);
+
+ /**
+ * Removes all modifiers from the list.
+ * Geometry modifiers may additionally invalidate the point cache.
+ */
+ void clearModifiers();
+
+ /**
+ * Drives the full render loop: gets cached points, runs render modifiers, calls renderer.
+ *
+ * @param shape the shape to render
+ * @param orientation the orientation for point sampling
+ * @param renderer client-provided renderer
+ */
+ void render(Shape shape, Quaterniond orientation, ShapeRenderer renderer);
+
+ /**
+ * Returns a deep copy of this sampler with the same configuration.
+ * The clone must be fully independent from the original.
+ *
+ * @return a new sampler instance
+ */
PointSampler clone();
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/SamplingStyle.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/SamplingStyle.java
index d65ef9b..ba198d4 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/SamplingStyle.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/SamplingStyle.java
@@ -8,6 +8,11 @@ public enum SamplingStyle {
SURFACE,
FILL;
+ /**
+ * Returns a lowercase string representation of this style, e.g. {@code "outline"}.
+ *
+ * @return the lowercase enum name
+ */
@Override
public String toString() {
return name().toLowerCase();
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/ShapeRenderer.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/ShapeRenderer.java
new file mode 100644
index 0000000..aa0b9cf
--- /dev/null
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/ShapeRenderer.java
@@ -0,0 +1,38 @@
+package com.sovdee.shapes.sampling;
+
+import com.sovdee.shapes.modifiers.PointContext;
+
+/**
+ * Client-implemented interface for rendering shape points.
+ * The library drives the render loop; the client handles the actual output (e.g. NMS packets).
+ *
+ * Implementations may cast the {@link PointContext} to their own subclass (e.g. {@code RenderContext})
+ * to read render-specific properties written by render modifiers.
+ */
+public interface ShapeRenderer {
+
+ /**
+ * Called once before the point loop. Use to allocate buffers, pre-compute shared state, etc.
+ *
+ * @param totalPoints the number of points that will be rendered
+ */
+ void begin(int totalPoints);
+
+ /**
+ * Called for each point after all render modifiers have been applied.
+ * Should be allocation-free.
+ *
+ * @param point the mutable per-point context; cast to your subclass to access render properties
+ */
+ void renderPoint(Context point);
+
+ /**
+ * @return Returns the context this renderer uses.
+ */
+ Context getContext();
+
+ /**
+ * Called after all points have been rendered. Flush buffers, send remaining packets, etc.
+ */
+ void end();
+}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/AbstractShape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/AbstractShape.java
index 1ba1478..2d96188 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/AbstractShape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/AbstractShape.java
@@ -6,8 +6,8 @@
import org.joml.Quaterniond;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.List;
/**
* Base implementation of {@link Shape} providing spatial transform, versioning,
@@ -22,6 +22,11 @@ public abstract class AbstractShape implements Shape {
private long version = 0;
private PointSampler pointSampler;
+ /**
+ * Initialises a new shape with an identity orientation, scale {@code 1.0}, zero offset,
+ * and a fresh {@link DefaultPointSampler}. Subclasses must call {@code super()} before
+ * setting dimension-specific fields.
+ */
public AbstractShape() {
this.orientation = new Quaterniond();
this.scale = 1;
@@ -31,29 +36,51 @@ public AbstractShape() {
// --- Spatial transform ---
+ /**
+ * {@inheritDoc}
+ * Returns a defensive copy so callers cannot mutate the internal quaternion.
+ */
@Override
public Quaterniond getOrientation() {
return new Quaterniond(orientation);
}
+ /**
+ * {@inheritDoc}
+ * The value is copied into the internal quaternion; the argument is not retained.
+ */
@Override
public void setOrientation(Quaterniond orientation) {
this.orientation.set(orientation);
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public double getScale() { return scale; }
+ /**
+ * {@inheritDoc}
+ */
@Override
public void setScale(double scale) {
this.scale = scale;
}
+ /**
+ * {@inheritDoc}
+ * Returns a defensive copy so callers cannot mutate the internal offset.
+ */
@Override
public Vector3d getOffset() {
return new Vector3d(offset);
}
+ /**
+ * {@inheritDoc}
+ * The provided vector is stored directly (not copied); callers should not mutate it afterward.
+ */
@Override
public void setOffset(Vector3d offset) {
this.offset = offset;
@@ -61,16 +88,28 @@ public void setOffset(Vector3d offset) {
// --- Oriented axes ---
+ /**
+ * {@inheritDoc}
+ * Computed by rotating {@code (1, 0, 0)} with the internal orientation quaternion.
+ */
@Override
public Vector3d getRelativeXAxis() {
return orientation.transform(new Vector3d(1, 0, 0));
}
+ /**
+ * {@inheritDoc}
+ * Computed by rotating {@code (0, 1, 0)} with the internal orientation quaternion.
+ */
@Override
public Vector3d getRelativeYAxis() {
return orientation.transform(new Vector3d(0, 1, 0));
}
+ /**
+ * {@inheritDoc}
+ * Computed by rotating {@code (0, 0, 1)} with the internal orientation quaternion.
+ */
@Override
public Vector3d getRelativeZAxis() {
return orientation.transform(new Vector3d(0, 0, 1));
@@ -78,6 +117,10 @@ public Vector3d getRelativeZAxis() {
// --- Change detection ---
+ /**
+ * {@inheritDoc}
+ * The counter starts at {@code 0} and is incremented each time {@link #invalidate()} is called.
+ */
@Override
public long getVersion() { return version; }
@@ -91,24 +134,55 @@ protected void invalidate() {
// --- Dynamic support ---
+ /**
+ * {@inheritDoc}
+ */
@Override
public boolean isDynamic() { return dynamic; }
+ /**
+ * {@inheritDoc}
+ */
@Override
public void setDynamic(boolean dynamic) { this.dynamic = dynamic; }
// --- Point generation defaults ---
+ /**
+ * Default surface implementation that delegates to {@link #generateOutline(List, double)}.
+ * Subclasses with a meaningful surface representation (e.g. {@link Circle}, {@link Sphere})
+ * should override this method.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
generateOutline(points, density);
}
+ /**
+ * Default filled implementation that delegates to {@link #generateSurface(List, double)}.
+ * Subclasses that can meaningfully fill their volume (e.g. {@link Circle}, {@link Sphere})
+ * should override this method.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points
+ */
@Override
- public void generateFilled(Set points, double density) {
+ public void generateFilled(List points, double density) {
generateSurface(points, density);
}
+ /**
+ * Generic fallback implementation that always returns a density of {@code 0.25}.
+ * Subclasses should override this with a formula appropriate to their geometry so that
+ * {@link PointSampler#setParticleCount(Shape, int)} produces accurate results.
+ *
+ * @param style the {@link SamplingStyle} being used (OUTLINE, SURFACE, or FILL)
+ * @param targetPointCount the desired number of points
+ * @return {@code 0.25} as a safe, conservative default
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
// Generic fallback — subclasses should override for accuracy
@@ -117,24 +191,52 @@ public double computeDensity(SamplingStyle style, int targetPointCount) {
// --- Geometry query ---
+ /**
+ * {@inheritDoc}
+ * Concrete subclasses must implement the containment test appropriate to their geometry.
+ */
@Override
public abstract boolean contains(Vector3d point);
// --- PointSampler ---
+ /**
+ * {@inheritDoc}
+ */
@Override
public PointSampler getPointSampler() { return pointSampler; }
+ /**
+ * {@inheritDoc}
+ */
@Override
public void setPointSampler(PointSampler sampler) { this.pointSampler = sampler; }
// --- Vertical fill helper ---
- protected static void fillVertically(Set points, double height, double density) {
- Set base = new LinkedHashSet<>(points);
+ /**
+ * Duplicates an existing slice of {@code points} at successive Y offsets to fill a vertical
+ * column of the given {@code height}. The Y step is derived from {@code density} so that
+ * layer spacing is as uniform as possible.
+ *
+ * The method reads all points in the range {@code [startIndex, points.size())} at the time
+ * of the call and appends copies translated by {@code y = density, 2*density, ...} up to
+ * (but not including) {@code height}.
+ *
+ * @param points the list of points to extend; the existing slice is treated as the base layer
+ * @param startIndex the index of the first point in the base layer
+ * @param height the total vertical extent to fill; must be positive
+ * @param density the desired spacing between vertical layers
+ */
+ protected static void fillVertically(List points, int startIndex, double height, double density) {
+ int baseSize = points.size() - startIndex;
double heightStep = height / Math.round(height / density);
- for (double y = 0; y < height; y += heightStep) {
- for (Vector3d v : base) {
+ if (points instanceof ArrayList> pointList) {
+ pointList.ensureCapacity(points.size() + baseSize * (int) ((height - heightStep) / heightStep + 1));
+ }
+ for (double y = heightStep; y < height; y += heightStep) {
+ for (int i = startIndex; i < startIndex + baseSize; i++) {
+ Vector3d v = points.get(i);
points.add(new Vector3d(v.x, y, v.z));
}
}
@@ -142,9 +244,22 @@ protected static void fillVertically(Set points, double height, double
// --- Replication ---
+ /**
+ * {@inheritDoc}
+ * Concrete subclasses must create a new instance with the same dimension-specific state and
+ * then call {@link #copyTo(Shape)} to propagate the base transform fields.
+ */
@Override
public abstract Shape clone();
+ /**
+ * {@inheritDoc}
+ * Copies orientation, scale, offset, dynamic flag, and a clone of the point sampler into
+ * {@code shape}. Dimension-specific fields are not touched.
+ *
+ * @param shape the target shape; must not be null
+ * @return {@code shape}, for chaining convenience
+ */
@Override
public Shape copyTo(Shape shape) {
shape.setOrientation(new Quaterniond(this.orientation));
@@ -152,14 +267,7 @@ public Shape copyTo(Shape shape) {
shape.setOffset(new Vector3d(this.offset));
shape.setDynamic(this.dynamic);
- // Clone sampler config
- PointSampler srcSampler = this.pointSampler;
- PointSampler destSampler = shape.getPointSampler();
- destSampler.setStyle(srcSampler.getStyle());
- destSampler.setDensity(srcSampler.getDensity());
- destSampler.setOrdering(srcSampler.getOrdering());
- if (srcSampler.getDrawContext() != null)
- destSampler.setDrawContext(srcSampler.getDrawContext().copy());
+ shape.setPointSampler(this.pointSampler.clone());
return shape;
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Arc.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Arc.java
index a4a8d7e..95f3efa 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Arc.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Arc.java
@@ -2,36 +2,87 @@
import org.joml.Vector3d;
-import java.util.Set;
+import java.util.List;
+/**
+ * An arc is a partial {@link Circle}: a sector of a circle swept from angle {@code 0} to
+ * {@code cutoffAngle} radians around the Y axis. When a non-zero height is provided it becomes
+ * a partial cylinder (a cylindrical wedge).
+ *
+ * The cutoff angle is clamped to {@code [0, 2π]}. A cutoff of {@code 2π} produces a full
+ * circle or cylinder, identical in geometry to the parent {@link Circle}. Angles are measured
+ * in the XZ plane starting from the positive X axis.
+ *
+ * Surface generation delegates to {@link #generateFilled} so that sector fills are consistent
+ * across all three {@link com.sovdee.shapes.sampling.SamplingStyle} modes.
+ */
public class Arc extends Circle implements CutoffShape {
+ /**
+ * Creates a flat (zero-height) arc with the given radius and angular span.
+ *
+ * @param radius the radius of the arc; clamped to at least {@link Shape#EPSILON}
+ * @param cutoffAngle the angular span of the arc in radians; clamped to {@code [0, 2π]}
+ */
public Arc(double radius, double cutoffAngle) {
super(radius);
this.cutoffAngle = Math.clamp(cutoffAngle, 0, Math.PI * 2);
}
+ /**
+ * Creates a cylindrical-wedge arc with the given radius, height, and angular span.
+ *
+ * @param radius the radius of the arc; clamped to at least {@link Shape#EPSILON}
+ * @param height the vertical extent of the arc; clamped to {@code >= 0}
+ * @param cutoffAngle the angular span of the arc in radians; clamped to {@code [0, 2π]}
+ */
public Arc(double radius, double height, double cutoffAngle) {
super(radius, height);
this.cutoffAngle = Math.clamp(cutoffAngle, 0, Math.PI * 2);
}
+ /**
+ * Generates surface points by delegating to {@link #generateFilled(List, double)} so that
+ * the surface of an arc sector is treated as a filled disc segment rather than just an outline.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
generateFilled(points, density);
}
+ /**
+ * {@inheritDoc}
+ *
+ * @return the cutoff angle in radians, in the range {@code [0, 2π]}
+ */
@Override
public double getCutoffAngle() {
return this.cutoffAngle;
}
+ /**
+ * {@inheritDoc}
+ * Clamps the given angle to {@code [0, 2π]} and invalidates the point cache.
+ *
+ * @param cutoffAngle the new cutoff angle in radians; clamped to {@code [0, 2π]}
+ */
@Override
public void setCutoffAngle(double cutoffAngle) {
this.cutoffAngle = Math.clamp(cutoffAngle, 0, Math.PI * 2);
invalidate();
}
+ /**
+ * Returns {@code true} if {@code point} is within the parent {@link Circle}'s disc/cylinder
+ * and its XZ angle is within the cutoff span. The angle is measured from the positive X axis
+ * and normalised to {@code [0, 2π]} using {@code atan2(z, x)}.
+ *
+ * @param point the point to test, in local coordinates
+ * @return {@code true} if the point lies inside this arc sector
+ */
@Override
public boolean contains(Vector3d point) {
if (!super.contains(point)) return false;
@@ -40,11 +91,22 @@ public boolean contains(Vector3d point) {
return angle <= cutoffAngle;
}
+ /**
+ * Returns a deep copy of this arc with identical radius, height, cutoff angle, and base
+ * transform state.
+ *
+ * @return a new {@link Arc} equal to this one
+ */
@Override
public Shape clone() {
return this.copyTo(new Arc(this.getRadius(), this.getHeight(), cutoffAngle));
}
+ /**
+ * Returns a human-readable description of this arc, including its radius, cutoff angle, and height.
+ *
+ * @return a string of the form {@code Arc{radius=..., cutoffAngle=..., height=...}}
+ */
@Override
public String toString() {
return "Arc{radius=" + this.getRadius() + ", cutoffAngle=" + cutoffAngle + ", height=" + this.getHeight() + '}';
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/BezierCurve.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/BezierCurve.java
index 734f01a..15b34ec 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/BezierCurve.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/BezierCurve.java
@@ -5,7 +5,6 @@
import java.util.ArrayList;
import java.util.List;
-import java.util.Set;
import java.util.function.Supplier;
/**
@@ -17,41 +16,70 @@ public class BezierCurve extends AbstractShape {
private List controlPoints;
private Supplier> controlPointsSupplier;
+ /**
+ * Creates a static Bezier curve from a fixed list of control points.
+ * The points are deep-copied so subsequent mutation of the list has no effect.
+ *
+ * @param controlPoints the control points defining the curve; must contain at least 2 elements
+ * @throws IllegalArgumentException if fewer than 2 control points are provided
+ */
public BezierCurve(List controlPoints) {
super();
if (controlPoints.size() < 2)
throw new IllegalArgumentException("A bezier curve must have at least 2 control points.");
- this.controlPoints = new ArrayList<>();
+ this.controlPoints = new ArrayList<>(controlPoints.size());
for (Vector3d cp : controlPoints)
this.controlPoints.add(new Vector3d(cp));
}
+ /**
+ * Creates a dynamic Bezier curve whose control points are re-fetched from {@code controlPointsSupplier}
+ * on every render call. The shape is automatically marked {@link #setDynamic(boolean) dynamic}.
+ * The supplier is invoked once during construction to validate the initial point count.
+ *
+ * @param controlPointsSupplier a supplier that returns the live control points; must return
+ * at least 2 points on every invocation
+ * @throws IllegalArgumentException if the initial supplier result contains fewer than 2 points
+ */
public BezierCurve(Supplier> controlPointsSupplier) {
super();
this.controlPointsSupplier = controlPointsSupplier;
List pts = controlPointsSupplier.get();
if (pts.size() < 2)
throw new IllegalArgumentException("A bezier curve must have at least 2 control points.");
- this.controlPoints = new ArrayList<>();
- for (Vector3d cp : pts)
- this.controlPoints.add(new Vector3d(cp));
+ this.controlPoints = new ArrayList<>(pts);
setDynamic(true);
}
+ /**
+ * Copy constructor: creates a static Bezier curve with deep-copied control points from
+ * {@code curve}. The supplier is not copied; the result is always a static curve.
+ *
+ * @param curve the source curve to copy control points from; must not be null
+ */
public BezierCurve(BezierCurve curve) {
super();
- this.controlPoints = new ArrayList<>();
+ this.controlPoints = new ArrayList<>(curve.controlPoints.size());
for (Vector3d cp : curve.controlPoints)
this.controlPoints.add(new Vector3d(cp));
}
+ /**
+ * Generates points along the Bezier curve using the de Casteljau algorithm and appends them
+ * to {@code points}. If a control-points supplier is set it is invoked now to refresh the
+ * control point list before sampling.
+ *
+ * The number of steps is {@code estimateLength() / density}, so smaller {@code density} values
+ * produce a finer approximation of the smooth curve.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired arc-length spacing between consecutive curve samples
+ */
@Override
- public void generateOutline(Set points, double density) {
+ public void generateOutline(List points, double density) {
if (controlPointsSupplier != null) {
List pts = controlPointsSupplier.get();
- this.controlPoints = new ArrayList<>();
- for (Vector3d cp : pts)
- this.controlPoints.add(new Vector3d(cp));
+ this.controlPoints = new ArrayList<>(pts);
}
int steps = (int) (estimateLength() / density);
int n = controlPoints.size();
@@ -82,12 +110,30 @@ private double estimateLength() {
return dist;
}
+ /**
+ * Computes the density required to produce approximately {@code targetPointCount} points along
+ * this curve. The result is {@code estimateLength() / targetPointCount}, where
+ * {@code estimateLength()} is the sum of straight-line distances between consecutive control points.
+ *
+ * @param style ignored; the Bezier curve has only one meaningful sampling mode
+ * @param targetPointCount the desired number of curve samples; clamped to at least 1
+ * @return the density value to pass to {@link #generateOutline(List, double)}
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(targetPointCount, 1);
return estimateLength() / count;
}
+ /**
+ * Returns {@code true} if {@code point} lies approximately on the curve.
+ * The test uses a high-density sampling of the curve (approximately one sample per {@code 0.1}
+ * units of chord length) and checks whether any sampled point is within {@link Shape#EPSILON}
+ * of {@code point}.
+ *
+ * @param point the point to test, in local coordinates
+ * @return {@code true} if the point is within {@link Shape#EPSILON} of the curve
+ */
@Override
public boolean contains(Vector3d point) {
// Approximate: check distance to nearest sampled point
@@ -110,21 +156,46 @@ public boolean contains(Vector3d point) {
return false;
}
+ /**
+ * Returns the current list of control points. For dynamic curves this reflects the most
+ * recently fetched values from the supplier (i.e. after the last {@link #generateOutline} call).
+ *
+ * @return the live control point list; modifications affect the internal state directly
+ */
public List getControlPoints() {
return controlPoints;
}
+ /**
+ * Replaces the control points of this curve with a deep copy of {@code controlPoints} and
+ * invalidates the point cache. The supplier is not updated.
+ *
+ * @param controlPoints the new control points; must not be null or empty
+ */
public void setControlPoints(List controlPoints) {
- this.controlPoints = new ArrayList<>();
+ this.controlPoints = new ArrayList<>(controlPoints.size());
for (Vector3d cp : controlPoints)
this.controlPoints.add(new Vector3d(cp));
invalidate();
}
+ /**
+ * Returns the supplier used to refresh control points on each render call, or {@code null}
+ * if this is a static curve constructed from a fixed point list.
+ *
+ * @return the control-points supplier, or {@code null} for static curves
+ */
public Supplier> getControlPointsSupplier() {
return controlPointsSupplier;
}
+ /**
+ * Returns a deep copy of this curve. If a control-points supplier is present the clone shares
+ * the same supplier reference (suppliers are not themselves cloneable). Otherwise, control
+ * points are deep-copied. Base transform state is copied via {@link #copyTo(Shape)}.
+ *
+ * @return a new {@link BezierCurve} with the same geometry and transform state
+ */
@Override
public Shape clone() {
BezierCurve clone;
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Circle.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Circle.java
index 5e7b510..d7b5d32 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Circle.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Circle.java
@@ -3,19 +3,55 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
-
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A circle (or cylinder when {@code height > 0}) centred at the local origin in the XZ plane.
+ *
+ * With {@code height == 0} the three generation modes produce:
+ *
+ * - {@link #generateOutline} — a ring of points at the given radius
+ * - {@link #generateSurface} — a filled disc (solid circle)
+ * - {@link #generateFilled} — same as surface for a flat shape
+ *
+ * With {@code height > 0} the modes produce:
+ *
+ * - {@link #generateOutline} — two rings at Y = 0 and Y = height, connected by vertical lines
+ * - {@link #generateSurface} — a full cylinder (two discs + wall)
+ * - {@link #generateFilled} — a solid cylindrical volume
+ *
+ *
+ * The protected field {@code cutoffAngle} is inherited by {@link Arc} to limit the angular span.
+ * In a plain {@code Circle} this is always {@code 2π}. All angles are measured from the positive
+ * X axis in the XZ plane.
+ *
+ * {@code Circle} implements both {@link RadialShape} and {@link LWHShape}; the {@code length} and
+ * {@code width} dimensions are not meaningful for a circle and return/accept {@code 0}. Only
+ * {@code height} is active via {@link LWHShape}.
+ */
public class Circle extends AbstractShape implements RadialShape, LWHShape {
private double radius;
protected double cutoffAngle;
private double height;
+ /**
+ * Creates a flat circle (height = 0) with the given radius.
+ *
+ * @param radius the radius of the circle; clamped to at least {@link Shape#EPSILON}
+ */
public Circle(double radius) {
this(radius, 0);
}
+ /**
+ * Creates a circle or cylinder with the given radius and height.
+ * A height of {@code 0} produces a flat 2-D circle.
+ *
+ * @param radius the radius; clamped to at least {@link Shape#EPSILON}
+ * @param height the vertical extent; clamped to {@code >= 0}
+ */
public Circle(double radius, double height) {
super();
this.radius = Math.max(radius, Shape.EPSILON);
@@ -25,68 +61,99 @@ public Circle(double radius, double height) {
// --- Static calculation methods ---
- public static Set calculateCircle(double radius, double density, double cutoffAngle) {
- Set points = new LinkedHashSet<>();
+ /**
+ * Appends points evenly spaced along a circular arc of the given {@code radius} in the XZ
+ * plane. The angular step is {@code density / radius} radians, so arc-length spacing between
+ * consecutive points equals approximately {@code density}.
+ *
+ * @param points the list to append to; must not be null
+ * @param radius the radius of the circle
+ * @param density the desired arc-length spacing between points
+ * @param cutoffAngle the angular span to cover, in radians ({@code [0, 2π]})
+ */
+ public static void calculateCircle(List points, double radius, double density, double cutoffAngle) {
double stepSize = density / radius;
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (cutoffAngle / stepSize) + 1);
for (double theta = 0; theta < cutoffAngle; theta += stepSize) {
points.add(new Vector3d(Math.cos(theta) * radius, 0, Math.sin(theta) * radius));
}
- return points;
}
- public static Set calculateDisc(double radius, double density, double cutoffAngle) {
- Set points = new LinkedHashSet<>();
+ /**
+ * Appends points filling the area of a disc sector in the XZ plane by stacking concentric
+ * rings from {@code density} to {@code radius} (inclusive) at intervals of {@code density}.
+ * Each ring is generated by {@link #calculateCircle}.
+ *
+ * @param points the list to append to; must not be null
+ * @param radius the outer radius of the disc
+ * @param density the desired spacing between points and between concentric rings
+ * @param cutoffAngle the angular span of the sector, in radians ({@code [0, 2π]})
+ */
+ public static void calculateDisc(List points, double radius, double density, double cutoffAngle) {
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (cutoffAngle * radius * radius / (density * density * Math.PI)));
for (double subRadius = density; subRadius < radius; subRadius += density) {
- points.addAll(calculateCircle(subRadius, density, cutoffAngle));
+ calculateCircle(points, subRadius, density, cutoffAngle);
}
- points.addAll(calculateCircle(radius, density, cutoffAngle));
- return points;
+ calculateCircle(points, radius, density, cutoffAngle);
}
- public static Set calculateCylinder(double radius, double height, double density, double cutoffAngle) {
- Set points = calculateDisc(radius, density, cutoffAngle);
- // Top disc via direct loop
- Set top = new LinkedHashSet<>();
- for (Vector3d v : points) {
- top.add(new Vector3d(v.x, height, v.z));
+ /**
+ * Appends points filling a cylindrical (or wedge-cylinder) volume. The method generates:
+ *
+ * - A bottom disc at Y = 0.
+ * - A top disc at Y = {@code height} (copied from the bottom disc).
+ * - The curved wall, filled vertically via {@link #fillVertically}.
+ *
+ *
+ * @param points the list to append to; must not be null
+ * @param radius the radius of the cylinder
+ * @param height the height of the cylinder; must be positive
+ * @param density the desired spacing between points
+ * @param cutoffAngle the angular span of the cylinder sector, in radians ({@code [0, 2π]})
+ */
+ public static void calculateCylinder(List points, double radius, double height, double density, double cutoffAngle) {
+ // Bottom disc
+ int discStart = points.size();
+ calculateDisc(points, radius, density, cutoffAngle);
+ int discEnd = points.size();
+ // Top disc - copy bottom disc at height
+ for (int i = discStart; i < discEnd; i++) {
+ Vector3d v = points.get(i);
+ points.add(new Vector3d(v.x, height, v.z));
}
- points.addAll(top);
// Wall
- Set wall = calculateCircle(radius, density, cutoffAngle);
- fillVertically(wall, height, density);
- points.addAll(wall);
- return points;
+ int wallStart = points.size();
+ calculateCircle(points, radius, density, cutoffAngle);
+ fillVertically(points, wallStart, height, density);
}
// --- Generation methods ---
@Override
- public void generateOutline(Set points, double density) {
- Set circle = calculateCircle(radius, density, cutoffAngle);
+ public void generateOutline(List points, double density) {
+ int start = points.size();
+ calculateCircle(points, radius, density, cutoffAngle);
if (height != 0) {
- fillVertically(circle, height, density);
- points.addAll(circle);
- } else {
- points.addAll(circle);
+ fillVertically(points, start, height, density);
}
}
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
if (height != 0)
- points.addAll(calculateCylinder(radius, height, density, cutoffAngle));
+ calculateCylinder(points, radius, height, density, cutoffAngle);
else
- points.addAll(calculateDisc(radius, density, cutoffAngle));
+ calculateDisc(points, radius, density, cutoffAngle);
}
@Override
- public void generateFilled(Set points, double density) {
- Set disc = calculateDisc(radius, density, cutoffAngle);
+ public void generateFilled(List points, double density) {
+ int start = points.size();
+ calculateDisc(points, radius, density, cutoffAngle);
if (height != 0) {
- fillVertically(disc, height, density);
- points.addAll(disc);
- } else {
- points.addAll(disc);
+ fillVertically(points, start, height, density);
}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Cuboid.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Cuboid.java
index 57b1e0c..9ddedef 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Cuboid.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Cuboid.java
@@ -3,12 +3,20 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.Set;
+import java.util.List;
import java.util.function.Supplier;
/**
- * A cuboid shape, defined either by dimensions or by two corner vectors.
- * For dynamic (entity-following) cuboids, use the plugin-side DynamicCuboid wrapper.
+ * A cuboid (rectangular prism) shape, defined either by explicit length/width/height dimensions
+ * or by two opposing corner {@link Vector3d} positions. The cuboid is always axis-aligned in
+ * local space and centred at the origin, offset by any {@code centerOffset} computed from the
+ * corner positions.
+ *
+ * When constructed from two {@link java.util.function.Supplier Supplier<Vector3d>} corners the
+ * cuboid is marked as {@link AbstractShape#setDynamic(boolean) dynamic}, meaning the corner
+ * positions are re-evaluated each time {@link #beforeSampling(double)} is called.
+ *
+ * For entity-following dynamic cuboids in the plugin layer, use the {@code DynamicCuboid} wrapper.
*/
public class Cuboid extends AbstractShape implements LWHShape {
@@ -18,6 +26,15 @@ public class Cuboid extends AbstractShape implements LWHShape {
private Supplier cornerASupplier;
private Supplier cornerBSupplier;
+ /**
+ * Constructs a cuboid centred at the local origin with the given dimensions.
+ * Each half-dimension is clamped to at least {@link Shape#EPSILON} to prevent
+ * degenerate geometry.
+ *
+ * @param length extent along the X axis (total, not half)
+ * @param width extent along the Z axis (total, not half)
+ * @param height extent along the Y axis (total, not half)
+ */
public Cuboid(double length, double width, double height) {
super();
this.halfWidth = Math.max(width / 2, Shape.EPSILON);
@@ -25,6 +42,16 @@ public Cuboid(double length, double width, double height) {
this.halfHeight = Math.max(height / 2, Shape.EPSILON);
}
+ /**
+ * Constructs a cuboid from two opposing corner positions expressed as world-space
+ * {@link Vector3d} values. The cuboid's dimensions are derived from the axis-aligned
+ * bounding box of the two corners, and the {@code centerOffset} is set to the midpoint
+ * so that drawing is centred correctly.
+ *
+ * @param cornerA first corner of the cuboid
+ * @param cornerB second corner, diagonally opposite to {@code cornerA}
+ * @throws IllegalArgumentException if both corners are equal
+ */
public Cuboid(Vector3d cornerA, Vector3d cornerB) {
super();
if (cornerA.equals(cornerB))
@@ -35,6 +62,17 @@ public Cuboid(Vector3d cornerA, Vector3d cornerB) {
centerOffset = new Vector3d(cornerB).add(cornerA).mul(0.5);
}
+ /**
+ * Constructs a dynamic cuboid whose corners are provided by {@link Supplier} functions.
+ * The suppliers are called once during construction to compute initial dimensions,
+ * and again on every call to {@link #beforeSampling(double)} so that the cuboid tracks
+ * positions that change over time (e.g. entity locations).
+ *
+ * This constructor marks the shape as {@link AbstractShape#setDynamic(boolean) dynamic}.
+ *
+ * @param cornerA supplier returning the first corner position
+ * @param cornerB supplier returning the second corner position, diagonally opposite
+ */
public Cuboid(Supplier cornerA, Supplier cornerB) {
super();
this.cornerASupplier = cornerA;
@@ -53,6 +91,13 @@ private void calculateSteps(double density) {
heightStep = 2 * halfHeight / Math.round(2 * halfHeight / density);
}
+ /**
+ * Called before point sampling begins. If this cuboid was constructed with corner suppliers,
+ * re-evaluates those suppliers to update the half-dimensions to the current corner positions.
+ * Then pre-computes the per-axis step sizes used by the generation methods.
+ *
+ * @param density the target spacing between adjacent sample points
+ */
@Override
public void beforeSampling(double density) {
if (cornerASupplier != null && cornerBSupplier != null) {
@@ -65,13 +110,30 @@ public void beforeSampling(double density) {
calculateSteps(density);
}
+ /**
+ * Called after all points have been generated. Translates every point by the {@code centerOffset}
+ * so that corner-defined cuboids are positioned correctly in world space rather than being
+ * centred at the local origin.
+ *
+ * @param points the mutable list of sampled points to translate in place
+ */
@Override
- public void afterSampling(Set points) {
+ public void afterSampling(List points) {
points.forEach(vector -> vector.add(centerOffset));
}
+ /**
+ * Generates the 12-edge wireframe of the cuboid. Points are placed along each of the
+ * four parallel edges in the X, Y, and Z directions using the pre-computed step sizes.
+ * Corner vertices are included; interior edge steps along non-primary directions avoid
+ * re-adding the already-placed corner points.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the target spacing between adjacent points (pre-computed step sizes
+ * are used instead of {@code density} directly)
+ */
@Override
- public void generateOutline(Set points, double density) {
+ public void generateOutline(List points, double density) {
for (double x = -halfLength; x <= halfLength; x += lengthStep) {
points.add(new Vector3d(x, -halfHeight, -halfWidth));
points.add(new Vector3d(x, -halfHeight, halfWidth));
@@ -92,8 +154,17 @@ public void generateOutline(Set points, double density) {
}
}
+ /**
+ * Generates points covering all six faces of the cuboid. The top and bottom (XZ) faces are
+ * sampled as full grids; the front/back (XY) and left/right (YZ) faces fill in the remaining
+ * interior rows/columns to avoid duplicating edge points already added by the adjacent faces.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the target spacing between adjacent points (pre-computed step sizes
+ * are used instead of {@code density} directly)
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
for (double x = -halfLength; x <= halfLength; x += lengthStep) {
for (double z = -halfWidth; z <= halfWidth; z += widthStep) {
points.add(new Vector3d(x, -halfHeight, z));
@@ -114,8 +185,17 @@ public void generateSurface(Set points, double density) {
}
}
+ /**
+ * Generates a uniform 3-D grid of points filling the interior of the cuboid. Every lattice
+ * site within {@code [-halfLength, halfLength] × [-halfHeight, halfHeight] × [-halfWidth, halfWidth]}
+ * is included, using the pre-computed axis step sizes.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the target spacing between adjacent points (pre-computed step sizes
+ * are used instead of {@code density} directly)
+ */
@Override
- public void generateFilled(Set points, double density) {
+ public void generateFilled(List points, double density) {
for (double x = -halfLength; x <= halfLength; x += lengthStep) {
for (double y = -halfHeight; y <= halfHeight; y += heightStep) {
for (double z = -halfWidth; z <= halfWidth; z += widthStep) {
@@ -125,6 +205,21 @@ public void generateFilled(Set points, double density) {
}
}
+ /**
+ * Estimates the point spacing required to produce approximately {@code targetPointCount} points
+ * for the given {@link SamplingStyle}.
+ *
+ * - OUTLINE: {@code density = totalEdgeLength / count}, where total edge length
+ * is {@code 8 * (L/2 + H/2 + W/2)}
+ * - SURFACE: {@code density = sqrt(totalSurfaceArea / count)}, where surface area
+ * is {@code 2*(L*H + L*W + H*W)}
+ * - FILL: {@code density = cbrt(volume / count)}, where volume is {@code L*W*H}
+ *
+ *
+ * @param style the sampling style (OUTLINE, SURFACE, or FILL)
+ * @param targetPointCount the desired number of output points
+ * @return the estimated point spacing to achieve the target count
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(1, targetPointCount);
@@ -135,6 +230,14 @@ public double computeDensity(SamplingStyle style, int targetPointCount) {
};
}
+ /**
+ * Returns {@code true} if the given point lies inside or on the surface of this cuboid.
+ * Tests are performed in local (centred) space: the point is considered contained when
+ * {@code |x| ≤ halfLength && |y| ≤ halfHeight && |z| ≤ halfWidth}.
+ *
+ * @param point the point to test, in local shape space
+ * @return {@code true} if {@code point} is within the axis-aligned bounds of the cuboid
+ */
@Override
public boolean contains(Vector3d point) {
return Math.abs(point.x) <= halfLength &&
@@ -142,36 +245,89 @@ public boolean contains(Vector3d point) {
Math.abs(point.z) <= halfWidth;
}
+ /**
+ * Returns the total length of the cuboid along the X axis ({@code halfLength * 2}).
+ *
+ * @return the full X-axis extent
+ */
@Override
public double getLength() { return halfLength * 2; }
+ /**
+ * Sets the total length of the cuboid along the X axis. Values below {@link Shape#EPSILON}
+ * are clamped to {@code EPSILON} to prevent degenerate geometry. Invalidates the point cache.
+ *
+ * @param length the new X-axis extent (total, not half)
+ */
@Override
public void setLength(double length) {
this.halfLength = Math.max(length / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the total width of the cuboid along the Z axis ({@code halfWidth * 2}).
+ *
+ * @return the full Z-axis extent
+ */
@Override
public double getWidth() { return halfWidth * 2; }
+ /**
+ * Sets the total width of the cuboid along the Z axis. Values below {@link Shape#EPSILON}
+ * are clamped to {@code EPSILON}. Invalidates the point cache.
+ *
+ * @param width the new Z-axis extent (total, not half)
+ */
@Override
public void setWidth(double width) {
this.halfWidth = Math.max(width / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the total height of the cuboid along the Y axis ({@code halfHeight * 2}).
+ *
+ * @return the full Y-axis extent
+ */
@Override
public double getHeight() { return halfHeight * 2; }
+ /**
+ * Sets the total height of the cuboid along the Y axis. Values below {@link Shape#EPSILON}
+ * are clamped to {@code EPSILON}. Invalidates the point cache.
+ *
+ * @param height the new Y-axis extent (total, not half)
+ */
@Override
public void setHeight(double height) {
this.halfHeight = Math.max(height / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the {@link Supplier} used to compute the first corner of this cuboid, or
+ * {@code null} if this cuboid was not constructed with suppliers.
+ *
+ * @return the corner-A position supplier, or {@code null}
+ */
public Supplier getCornerASupplier() { return cornerASupplier; }
+
+ /**
+ * Returns the {@link Supplier} used to compute the second corner of this cuboid, or
+ * {@code null} if this cuboid was not constructed with suppliers.
+ *
+ * @return the corner-B position supplier, or {@code null}
+ */
public Supplier getCornerBSupplier() { return cornerBSupplier; }
+ /**
+ * Creates a deep copy of this cuboid, preserving all dimensions, the corner suppliers
+ * (if present), the {@code centerOffset}, and all inherited {@link AbstractShape} state
+ * copied via {@link AbstractShape#copyTo(AbstractShape)}.
+ *
+ * @return a new {@link Cuboid} identical to this one
+ */
@Override
public Shape clone() {
Cuboid cuboid;
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/CutoffShape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/CutoffShape.java
index 19d3760..ab773fd 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/CutoffShape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/CutoffShape.java
@@ -4,6 +4,21 @@
* Represents a shape that has a cutoff angle, like an arc.
*/
public interface CutoffShape extends Shape {
+
+ /**
+ * Returns the angular cutoff that limits how much of the shape's circumference is generated.
+ * The value is in radians in the range {@code [0, 2π]}, where {@code 2π} produces the full shape
+ * and smaller values truncate it to a partial arc or wedge.
+ *
+ * @return the cutoff angle in radians
+ */
double getCutoffAngle();
+
+ /**
+ * Sets the angular cutoff for this shape. Values are clamped to {@code [0, 2π]} by implementations.
+ * A value of {@code 2π} restores the full shape; a value of {@code 0} produces no points.
+ *
+ * @param cutoffAngle the new cutoff angle in radians; clamped to {@code [0, 2π]}
+ */
void setCutoffAngle(double cutoffAngle);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipse.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipse.java
index eb99dad..bf3ab4a 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipse.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipse.java
@@ -4,10 +4,21 @@
import org.joml.Vector3d;
import java.util.ArrayList;
-import java.util.LinkedHashSet;
import java.util.List;
-import java.util.Set;
+/**
+ * An ellipse (or extruded elliptical cylinder) defined by two radii along the X and Z axes
+ * and an optional height along the Y axis.
+ *
+ * When {@code height} is zero the shape is a flat 2-D ellipse lying in the XZ plane.
+ * When {@code height} is positive the shape is extruded into a full cylinder-like solid
+ * whose cross-sections are ellipses.
+ *
+ * Point sampling is delegated to the public static helpers {@link #calculateEllipse},
+ * {@link #calculateEllipticalDisc}, and {@link #calculateCylinder}, which are also reused by
+ * {@link Ellipsoid} and {@link EllipticalArc}. An arc cutoff is stored in {@link #cutoffAngle}
+ * (inherited by {@link EllipticalArc}) and defaults to {@code 2π} for a full ellipse.
+ */
public class Ellipse extends AbstractShape implements LWHShape {
private double xRadius;
@@ -15,10 +26,27 @@ public class Ellipse extends AbstractShape implements LWHShape {
private double height;
protected double cutoffAngle;
+ /**
+ * Constructs a flat 2-D ellipse with the given X- and Z-axis radii and zero height.
+ * Equivalent to {@code new Ellipse(xRadius, zRadius, 0)}.
+ *
+ * @param xRadius the semi-axis length along the X axis; clamped to at least {@link Shape#EPSILON}
+ * @param zRadius the semi-axis length along the Z axis; clamped to at least {@link Shape#EPSILON}
+ */
public Ellipse(double xRadius, double zRadius) {
this(xRadius, zRadius, 0);
}
+ /**
+ * Constructs a (possibly extruded) ellipse. When {@code height} is zero the result is a flat
+ * ellipse in the XZ plane. When {@code height} is positive the outline consists of a vertical
+ * ellipse ring extruded upward, the surface is a closed cylinder, and the fill is a solid
+ * elliptical prism.
+ *
+ * @param xRadius the semi-axis length along the X axis; clamped to at least {@link Shape#EPSILON}
+ * @param zRadius the semi-axis length along the Z axis; clamped to at least {@link Shape#EPSILON}
+ * @param height the extrusion distance along the Y axis; clamped to at least {@code 0}
+ */
public Ellipse(double xRadius, double zRadius, double height) {
super();
this.xRadius = Math.max(xRadius, Shape.EPSILON);
@@ -36,11 +64,26 @@ private static double ellipseCircumference(double r1, double r2) {
return Math.PI * (a + b) * (1 + 3 * h / (10 + Math.sqrt(4 - 3 * h)));
}
- public static List calculateEllipse(double r1, double r2, double density, double cutoffAngle) {
- List points = new ArrayList<>();
+ /**
+ * Appends points along the perimeter of an ellipse with semi-axes {@code r1} (X) and
+ * {@code r2} (Z), using an arc-length-adaptive angle step so that consecutive points are
+ * spaced approximately {@code density} units apart.
+ *
+ * Sampling stops when the accumulated angle reaches {@code cutoffAngle}. Pass {@code 2π}
+ * for a complete ellipse.
+ *
+ * @param points the list to which points are appended
+ * @param r1 semi-axis length along the X axis
+ * @param r2 semi-axis length along the Z axis
+ * @param density desired arc-length spacing between consecutive points
+ * @param cutoffAngle maximum sweep angle in radians ({@code 0} to {@code 2π})
+ */
+ public static void calculateEllipse(List points, double r1, double r2, double density, double cutoffAngle) {
double circumference = ellipseCircumference(r1, r2);
int steps = (int) Math.round(circumference / density);
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (steps * cutoffAngle / (2 * Math.PI)) + 1);
double theta = 0;
double angleStep = 0;
for (int i = 0; i < steps; i++) {
@@ -53,67 +96,127 @@ public static List calculateEllipse(double r1, double r2, double densi
angleStep = density / Math.sqrt(dx * dx + dy * dy);
theta += angleStep;
}
- return points;
}
- public static Set calculateEllipticalDisc(double r1, double r2, double density, double cutoffAngle) {
- Set points = new LinkedHashSet<>();
+ /**
+ * Appends points filling the area of an elliptical disc (filled 2-D ellipse) by sampling
+ * concentric ellipses at uniform radial increments from the outermost radius down to zero.
+ * Each ring is generated by {@link #calculateEllipse} with proportionally scaled radii.
+ *
+ * @param points the list to which points are appended
+ * @param r1 outer semi-axis length along the X axis
+ * @param r2 outer semi-axis length along the Z axis
+ * @param density desired arc-length spacing used for both the ring step and {@link #calculateEllipse}
+ * @param cutoffAngle angular cutoff applied to each ring ({@code 2π} for a full disc)
+ */
+ public static void calculateEllipticalDisc(List points, double r1, double r2, double density, double cutoffAngle) {
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (cutoffAngle * r1 * r2 / (density * density)));
int steps = (int) Math.round(Math.max(r1, r2) / density);
double r;
for (double i = 1; i <= steps; i += 1) {
r = i / steps;
- points.addAll(calculateEllipse(r1 * r, r2 * r, density, cutoffAngle));
+ calculateEllipse(points, r1 * r, r2 * r, density, cutoffAngle);
}
- return points;
}
- public static Set calculateCylinder(double r1, double r2, double height, double density, double cutoffAngle) {
- Set points = calculateEllipticalDisc(r1, r2, density, cutoffAngle);
- // Top disc via direct loop
- Set top = new LinkedHashSet<>();
- for (Vector3d v : points) {
- top.add(new Vector3d(v.x, height, v.z));
+ /**
+ * Appends points covering a solid elliptical cylinder: bottom disc, top disc (offset by
+ * {@code height} along Y), and the lateral wall extruded vertically between them.
+ *
+ * The bottom and top discs are generated via {@link #calculateEllipticalDisc}; the wall is
+ * generated via {@link #calculateEllipse} and then extended vertically using the inherited
+ * {@code fillVertically} helper.
+ *
+ * @param points the list to which points are appended
+ * @param r1 semi-axis along the X axis
+ * @param r2 semi-axis along the Z axis
+ * @param height extrusion distance along the Y axis
+ * @param density desired spacing between adjacent points
+ * @param cutoffAngle angular cutoff applied to discs and the elliptical wall ({@code 2π} for full)
+ */
+ public static void calculateCylinder(List points, double r1, double r2, double height, double density, double cutoffAngle) {
+ // Bottom disc
+ int discStart = points.size();
+ calculateEllipticalDisc(points, r1, r2, density, cutoffAngle);
+ int discEnd = points.size();
+ // Top disc - copy bottom disc at height
+ for (int i = discStart; i < discEnd; i++) {
+ Vector3d v = points.get(i);
+ points.add(new Vector3d(v.x, height, v.z));
}
- points.addAll(top);
// Wall
- Set wall = new LinkedHashSet<>(calculateEllipse(r1, r2, density, cutoffAngle));
- fillVertically(wall, height, density);
- points.addAll(wall);
- return points;
+ int wallStart = points.size();
+ calculateEllipse(points, r1, r2, density, cutoffAngle);
+ fillVertically(points, wallStart, height, density);
}
// --- Generation methods ---
+ /**
+ * Generates the outline of this ellipse. For a flat ellipse ({@code height == 0}) this is just
+ * the perimeter arc. When {@code height > 0} the outline ring is extruded vertically to form a
+ * rectangular strip connecting the bottom arc to the top arc.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points
+ */
@Override
- public void generateOutline(Set points, double density) {
- Set ellipse = new LinkedHashSet<>(calculateEllipse(xRadius, zRadius, density, cutoffAngle));
+ public void generateOutline(List points, double density) {
+ int start = points.size();
+ calculateEllipse(points, xRadius, zRadius, density, cutoffAngle);
if (height != 0) {
- fillVertically(ellipse, height, density);
- points.addAll(ellipse);
- } else {
- points.addAll(ellipse);
+ fillVertically(points, start, height, density);
}
}
+ /**
+ * Generates the surface of this ellipse. When {@code height == 0} the surface is the filled
+ * elliptical disc in the XZ plane. When {@code height > 0} the surface consists of the top
+ * and bottom discs plus the lateral wall, computed by {@link #calculateCylinder}.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
if (height != 0)
- points.addAll(calculateCylinder(xRadius, zRadius, height, density, cutoffAngle));
+ calculateCylinder(points, xRadius, zRadius, height, density, cutoffAngle);
else
- points.addAll(calculateEllipticalDisc(xRadius, zRadius, density, cutoffAngle));
+ calculateEllipticalDisc(points, xRadius, zRadius, density, cutoffAngle);
}
+ /**
+ * Generates a solid fill of this ellipse. For a flat ellipse ({@code height == 0}) this is
+ * identical to the surface (the filled disc). When {@code height > 0} the bottom disc is
+ * generated then extended vertically by {@code fillVertically} to produce a solid elliptical prism.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points
+ */
@Override
- public void generateFilled(Set points, double density) {
- Set disc = calculateEllipticalDisc(xRadius, zRadius, density, cutoffAngle);
+ public void generateFilled(List points, double density) {
+ int start = points.size();
+ calculateEllipticalDisc(points, xRadius, zRadius, density, cutoffAngle);
if (height != 0) {
- fillVertically(disc, height, density);
- points.addAll(disc);
- } else {
- points.addAll(disc);
+ fillVertically(points, start, height, density);
}
}
+ /**
+ * Estimates the point spacing required to produce approximately {@code targetPointCount} points
+ * for the given {@link SamplingStyle}.
+ *
+ * - OUTLINE: uses Ramanujan's first approximation for the ellipse circumference divided
+ * by {@code count}
+ * - SURFACE / FILL: {@code density = sqrt(π·xRadius·zRadius / count)}, the spacing
+ * that gives the target count over the disc area
+ *
+ *
+ * @param style the sampling style (OUTLINE, SURFACE, or FILL)
+ * @param targetPointCount the desired number of output points
+ * @return the estimated point spacing to achieve the target count
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(targetPointCount, 1);
@@ -127,6 +230,15 @@ public double computeDensity(SamplingStyle style, int targetPointCount) {
};
}
+ /**
+ * Returns {@code true} if the given point lies inside or on the boundary of this ellipse.
+ * The test first checks the normalised ellipse equation {@code (x/xRadius)² + (z/zRadius)² ≤ 1}.
+ * If the shape has height, the Y coordinate must additionally satisfy {@code 0 ≤ y ≤ height};
+ * otherwise the point must lie in the XZ plane ({@code |y| < EPSILON}).
+ *
+ * @param point the point to test in local shape space
+ * @return {@code true} if the point is contained within the ellipse (or cylinder)
+ */
@Override
public boolean contains(Vector3d point) {
double nx = point.x / xRadius;
@@ -136,33 +248,72 @@ public boolean contains(Vector3d point) {
return Math.abs(point.y) < EPSILON;
}
+ /**
+ * Returns the full diameter of the ellipse along the X axis ({@code xRadius * 2}).
+ *
+ * @return the total X-axis extent
+ */
@Override
public double getLength() { return xRadius * 2; }
+ /**
+ * Sets the X-axis semi-axis from half of {@code length}. Values below {@link Shape#EPSILON}
+ * are clamped. Invalidates the point cache.
+ *
+ * @param length the new total X-axis extent (diameter, not radius)
+ */
@Override
public void setLength(double length) {
xRadius = Math.max(length / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the full diameter of the ellipse along the Z axis ({@code zRadius * 2}).
+ *
+ * @return the total Z-axis extent
+ */
@Override
public double getWidth() { return zRadius * 2; }
+ /**
+ * Sets the Z-axis semi-axis from half of {@code width}. Values below {@link Shape#EPSILON}
+ * are clamped. Invalidates the point cache.
+ *
+ * @param width the new total Z-axis extent (diameter, not radius)
+ */
@Override
public void setWidth(double width) {
zRadius = Math.max(width / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the extrusion height along the Y axis, or {@code 0} for a flat ellipse.
+ *
+ * @return the Y-axis extrusion distance
+ */
@Override
public double getHeight() { return height; }
+ /**
+ * Sets the extrusion height along the Y axis. A value of {@code 0} produces a flat 2-D ellipse.
+ * Negative values are clamped to {@code 0}. Invalidates the point cache.
+ *
+ * @param height the new Y-axis extrusion distance
+ */
@Override
public void setHeight(double height) {
this.height = Math.max(height, 0);
invalidate();
}
+ /**
+ * Creates a deep copy of this ellipse, preserving radii, height, the inherited cutoff angle,
+ * and all {@link AbstractShape} state copied via {@link AbstractShape#copyTo(AbstractShape)}.
+ *
+ * @return a new {@link Ellipse} identical to this one
+ */
@Override
public Shape clone() {
return this.copyTo(new Ellipse(xRadius, zRadius, height));
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipsoid.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipsoid.java
index cab4100..32ebcee 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipsoid.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Ellipsoid.java
@@ -5,10 +5,21 @@
import org.joml.Quaterniond;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
+import java.util.ArrayList;
import java.util.List;
-import java.util.Set;
+/**
+ * A 3-D ellipsoid defined by three independent semi-axis radii along the X, Y, and Z axes.
+ *
+ * Outline sampling produces the three principal great-circle ellipses (XZ, XY, YZ planes).
+ * Surface sampling generates a latitude-strip decomposition: the longest horizontal cross-section
+ * ellipse (XZ or XY/YZ depending on which radius is larger) is used as the profile, and rings
+ * at each latitude are computed from the ellipsoid equation. Filled sampling nests concentric
+ * scaled ellipsoids to fill the interior.
+ *
+ * A point {@code (x, y, z)} is inside the ellipsoid when
+ * {@code (x/xRadius)² + (y/yRadius)² + (z/zRadius)² ≤ 1}.
+ */
public class Ellipsoid extends AbstractShape implements LWHShape {
private static final Quaterniond XY_ROTATION = new Quaterniond().rotateX(Math.PI / 2);
@@ -17,6 +28,14 @@ public class Ellipsoid extends AbstractShape implements LWHShape {
protected double yRadius;
protected double zRadius;
+ /**
+ * Constructs an ellipsoid with the given semi-axis lengths. Each radius is clamped to at
+ * least {@link Shape#EPSILON} to prevent degenerate geometry.
+ *
+ * @param xRadius semi-axis length along the X axis
+ * @param yRadius semi-axis length along the Y axis
+ * @param zRadius semi-axis length along the Z axis
+ */
public Ellipsoid(double xRadius, double yRadius, double zRadius) {
super();
this.xRadius = Math.max(xRadius, Shape.EPSILON);
@@ -24,52 +43,92 @@ public Ellipsoid(double xRadius, double yRadius, double zRadius) {
this.zRadius = Math.max(zRadius, Shape.EPSILON);
}
+ /**
+ * Generates the wireframe outline of the ellipsoid: three full great-circle ellipses in the
+ * XZ (horizontal), XY, and YZ planes. The XY and YZ ellipses are rotated into position using
+ * pre-computed quaternion constants so they lie correctly in their respective planes.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points along each ellipse
+ */
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(Ellipse.calculateEllipse(xRadius, zRadius, density, 2 * Math.PI));
- points.addAll(VectorUtil.transform(XY_ROTATION, Ellipse.calculateEllipse(xRadius, yRadius, density, 2 * Math.PI)));
- points.addAll(VectorUtil.transform(ZY_ROTATION, Ellipse.calculateEllipse(yRadius, zRadius, density, 2 * Math.PI)));
+ public void generateOutline(List points, double density) {
+ Ellipse.calculateEllipse(points, xRadius, zRadius, density, 2 * Math.PI);
+ int start = points.size();
+ Ellipse.calculateEllipse(points, xRadius, yRadius, density, 2 * Math.PI);
+ VectorUtil.transform(XY_ROTATION, points, start);
+ start = points.size();
+ Ellipse.calculateEllipse(points, yRadius, zRadius, density, 2 * Math.PI);
+ VectorUtil.transform(ZY_ROTATION, points, start);
}
+ /**
+ * Generates points covering the surface of the ellipsoid using a latitude-strip approach.
+ * The longest in-plane cross-section ellipse (XY or YZ depending on whether {@code xRadius >
+ * zRadius}) is used as the profile from which latitude angles are derived. At each latitude
+ * a horizontal ring scaled by {@code cos(θ)} is added, along with its mirror below the equator.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points
+ */
@Override
- public void generateSurface(Set points, double density) {
- List ellipse;
+ public void generateSurface(List points, double density) {
+ List ellipse = new ArrayList<>();
if (xRadius > zRadius) {
- ellipse = VectorUtil.transform(XY_ROTATION, Ellipse.calculateEllipse(xRadius, yRadius, density, 2 * Math.PI));
+ Ellipse.calculateEllipse(ellipse, xRadius, yRadius, density, 2 * Math.PI);
+ VectorUtil.transform(XY_ROTATION, ellipse);
} else {
- ellipse = VectorUtil.transform(ZY_ROTATION, Ellipse.calculateEllipse(yRadius, zRadius, density, 2 * Math.PI));
+ Ellipse.calculateEllipse(ellipse, yRadius, zRadius, density, 2 * Math.PI);
+ VectorUtil.transform(ZY_ROTATION, ellipse);
}
- points.addAll(generateEllipsoid(ellipse, 1, density));
+ generateEllipsoid(points, ellipse, 1, density);
}
+ /**
+ * Generates points filling the interior of the ellipsoid by nesting concentric scaled copies
+ * of the surface, stepping the scale factor from 1 down to 0. Each scaled shell is generated
+ * by the same latitude-strip method used in {@link #generateSurface}, applied to a proportionally
+ * scaled version of the radii.
+ *
+ * @param points the list to which generated points are appended
+ * @param density the desired spacing between adjacent points
+ */
@Override
- public void generateFilled(Set points, double density) {
- List ellipse;
+ public void generateFilled(List points, double density) {
double radius = Math.max(xRadius, zRadius);
int steps = (int) Math.round(radius / density);
+ List ellipse = new ArrayList<>();
for (int i = steps; i > 0; i--) {
double r = (i / (double) steps);
+ ellipse.clear();
if (xRadius > zRadius) {
- ellipse = VectorUtil.transform(XY_ROTATION, Ellipse.calculateEllipse(xRadius * r, yRadius * r, density, 2 * Math.PI));
+ Ellipse.calculateEllipse(ellipse, xRadius * r, yRadius * r, density, 2 * Math.PI);
+ VectorUtil.transform(XY_ROTATION, ellipse);
} else {
- ellipse = VectorUtil.transform(ZY_ROTATION, Ellipse.calculateEllipse(yRadius * r, zRadius * r, density, 2 * Math.PI));
+ Ellipse.calculateEllipse(ellipse, yRadius * r, zRadius * r, density, 2 * Math.PI);
+ VectorUtil.transform(ZY_ROTATION, ellipse);
}
- points.addAll(generateEllipsoid(ellipse, r, density));
+ generateEllipsoid(points, ellipse, r, density);
}
}
- private Set generateEllipsoid(List ellipse, double radius, double density) {
- Set points = new LinkedHashSet<>();
+ private void generateEllipsoid(List points, List ellipse, double radius, double density) {
for (int i = 0; i < Math.ceil(ellipse.size() / 4.0); i++) {
double y = ellipse.get(i).y;
double theta = Math.asin(y / (yRadius * radius));
- for (Vector3d v2 : Ellipse.calculateEllipse(radius * xRadius * Math.cos(theta), radius * zRadius * Math.cos(theta), density, 2 * Math.PI)) {
- points.add(new Vector3d(v2.x, y, v2.z));
- points.add(new Vector3d(v2.x, -y, v2.z));
+ // Add ring points, setting y in-place and adding mirrored copies
+ int ringStart = points.size();
+ Ellipse.calculateEllipse(points, radius * xRadius * Math.cos(theta), radius * zRadius * Math.cos(theta), density, 2 * Math.PI);
+ int ringEnd = points.size();
+ for (int j = ringStart; j < ringEnd; j++) {
+ Vector3d v = points.get(j);
+ v.y = y;
+ if (Math.abs(y) > EPSILON) {
+ points.add(new Vector3d(v.x, -y, v.z));
+ }
}
}
- points.addAll(Ellipse.calculateEllipse(radius * xRadius, radius * zRadius, density, 2 * Math.PI));
- return points;
+ Ellipse.calculateEllipse(points, radius * xRadius, radius * zRadius, density, 2 * Math.PI);
}
@Override
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/EllipticalArc.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/EllipticalArc.java
index faa7541..458ef97 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/EllipticalArc.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/EllipticalArc.java
@@ -2,7 +2,7 @@
import org.joml.Vector3d;
-import java.util.Set;
+import java.util.List;
public class EllipticalArc extends Ellipse implements CutoffShape {
@@ -16,7 +16,7 @@ public EllipticalArc(double xRadius, double zRadius, double height, double cutof
}
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
generateFilled(points, density);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Heart.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Heart.java
index a82ff16..b49063f 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Heart.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Heart.java
@@ -3,8 +3,8 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.List;
public class Heart extends AbstractShape implements LWHShape {
@@ -19,26 +19,26 @@ public Heart(double length, double width, double eccentricity) {
this.eccentricity = Math.max(eccentricity, 1);
}
- private static Set calculateHeart(double length, double width, double eccentricity, double density) {
- Set points = new LinkedHashSet<>();
+ private static void calculateHeart(List points, double length, double width, double eccentricity, double density) {
double angleStep = 4 / 3.0 * density / (width + length);
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (Math.PI * 2 / angleStep) + 1);
for (double theta = 0; theta < Math.PI * 2; theta += angleStep) {
double x = width * Math.pow(Math.sin(theta), 3);
double y = length * (Math.cos(theta) - 1 / eccentricity * Math.cos(2 * theta) - 1.0 / 6 * Math.cos(3 * theta) - 1.0 / 16 * Math.cos(4 * theta));
points.add(new Vector3d(x, 0, y));
}
- return points;
}
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(calculateHeart(length / 2, width / 2, eccentricity, density));
+ public void generateOutline(List points, double density) {
+ calculateHeart(points, length / 2, width / 2, eccentricity, density);
}
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
for (double w = width, l = length; w > 0 && l > 0; w -= density * 1.5, l -= density * 1.5) {
- points.addAll(calculateHeart(l / 2, w / 2, eccentricity, density));
+ calculateHeart(points, l / 2, w / 2, eccentricity, density);
}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Helix.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Helix.java
index 4062b1e..4aa64c2 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Helix.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Helix.java
@@ -3,8 +3,8 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.List;
public class Helix extends AbstractShape implements RadialShape, LWHShape {
@@ -30,31 +30,31 @@ public Helix(double radius, double height, double slope, int direction) {
this.direction = direction;
}
- private static Set calculateHelix(double radius, double height, double slope, int direction, double density) {
- Set points = new LinkedHashSet<>();
+ private static void calculateHelix(List points, double radius, double height, double slope, int direction, double density) {
if (radius <= 0 || height <= 0) {
- return points;
+ return;
}
double loops = Math.abs(height / slope);
double length = slope * slope + radius * radius;
double stepSize = density / length;
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + (int) (loops / stepSize) + 1);
for (double t = 0; t < loops; t += stepSize) {
double x = radius * Math.cos(direction * t);
double z = radius * Math.sin(direction * t);
points.add(new Vector3d(x, t * slope, z));
}
- return points;
}
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(calculateHelix(radius, height, slope, direction, density));
+ public void generateOutline(List points, double density) {
+ calculateHelix(points, radius, height, slope, direction, density);
}
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
for (double r = radius; r > 0; r -= density) {
- points.addAll(calculateHelix(r, height, slope, direction, density));
+ calculateHelix(points, r, height, slope, direction, density);
}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/IrregularPolygon.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/IrregularPolygon.java
index 1a6092c..20147cb 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/IrregularPolygon.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/IrregularPolygon.java
@@ -5,9 +5,8 @@
import java.util.ArrayList;
import java.util.Collection;
-import java.util.LinkedHashSet;
import java.util.List;
-import java.util.Set;
+
public class IrregularPolygon extends AbstractShape implements LWHShape {
@@ -46,17 +45,18 @@ private void setBounds(Collection vertices) {
}
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(Line.connectPoints(vertices, density));
- points.addAll(Line.calculateLine(vertices.get(0), vertices.get(vertices.size() - 1), density));
+ public void generateOutline(List points, double density) {
+ int start = points.size();
+ Line.connectPoints(points, vertices, density);
+ Line.calculateLine(points, vertices.get(0), vertices.get(vertices.size() - 1), density);
if (height != 0) {
- Set upperPoints = new LinkedHashSet<>();
- for (Vector3d v : points) {
- upperPoints.add(new Vector3d(v.x, height, v.z));
+ int end = points.size();
+ for (int i = start; i < end; i++) {
+ Vector3d v = points.get(i);
+ points.add(new Vector3d(v.x, height, v.z));
}
- points.addAll(upperPoints);
for (Vector3d v : vertices) {
- points.addAll(Line.calculateLine(v, new Vector3d(v.x, height, v.z), density));
+ Line.calculateLine(points, v, new Vector3d(v.x, height, v.z), density);
}
}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/LWHShape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/LWHShape.java
index 54ce72a..829bb6f 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/LWHShape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/LWHShape.java
@@ -4,10 +4,49 @@
* Represents a shape that has a length, width, and/or height.
*/
public interface LWHShape extends Shape {
+
+ /**
+ * Returns the length of this shape along its primary axis (typically X).
+ * Not all implementors use all three dimensions; those that do not use length may return {@code 0}.
+ *
+ * @return the length, in the shape's local units
+ */
double getLength();
+
+ /**
+ * Sets the length of this shape along its primary axis.
+ *
+ * @param length the new length, in the shape's local units
+ */
void setLength(double length);
+
+ /**
+ * Returns the width of this shape (typically along the Z axis).
+ * Not all implementors use all three dimensions; those that do not use width may return {@code 0}.
+ *
+ * @return the width, in the shape's local units
+ */
double getWidth();
+
+ /**
+ * Sets the width of this shape.
+ *
+ * @param width the new width, in the shape's local units
+ */
void setWidth(double width);
+
+ /**
+ * Returns the height of this shape (typically along the Y axis).
+ * A height of {@code 0} collapses the shape to a flat, 2-D cross-section.
+ *
+ * @return the height, in the shape's local units
+ */
double getHeight();
+
+ /**
+ * Sets the height of this shape. A value of {@code 0} collapses the shape to a 2-D cross-section.
+ *
+ * @param height the new height, in the shape's local units; must be {@code >= 0}
+ */
void setHeight(double height);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Line.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Line.java
index 7c830d4..8ede8bc 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Line.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Line.java
@@ -3,24 +3,42 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
+import java.util.ArrayList;
import java.util.List;
-import java.util.Set;
import java.util.function.Supplier;
/**
- * A line shape defined by two vector endpoints.
- * Supports both static endpoints and dynamic suppliers for entity-following.
+ * A straight-line segment defined by two vector endpoints, lying along the direction from start
+ * to end. The line implements {@link LWHShape} where only {@code length} (the distance between
+ * the endpoints) is meaningful; {@code width} and {@code height} always return {@code 0}.
+ *
+ * Both static and dynamic (supplier-based) endpoints are supported. Dynamic lines automatically
+ * mark themselves as {@link AbstractShape#isDynamic() dynamic} so the point cache is invalidated
+ * every frame.
+ *
*/
public class Line extends AbstractShape implements LWHShape {
private Supplier startSupplier;
private Supplier endSupplier;
+ /**
+ * Constructs a line from the origin {@code (0, 0, 0)} to the given endpoint.
+ *
+ * @param end the far endpoint of the line; must differ from the origin
+ */
public Line(Vector3d end) {
this(new Vector3d(0, 0, 0), end);
}
+ /**
+ * Constructs a static line between two explicit endpoints.
+ * The supplied vectors are copied so subsequent external mutations do not affect this shape.
+ *
+ * @param start the starting endpoint
+ * @param end the ending endpoint; must differ from {@code start}
+ * @throws IllegalArgumentException if {@code start} equals {@code end}
+ */
public Line(Vector3d start, Vector3d end) {
super();
if (start.equals(end))
@@ -31,6 +49,14 @@ public Line(Vector3d start, Vector3d end) {
this.endSupplier = () -> new Vector3d(e);
}
+ /**
+ * Constructs a dynamic line whose endpoints are resolved fresh each frame via suppliers.
+ * This variant marks the shape as {@link AbstractShape#isDynamic() dynamic} so points are
+ * regenerated every draw call (useful for entity-following lines).
+ *
+ * @param start a supplier that returns the current start position
+ * @param end a supplier that returns the current end position
+ */
public Line(Supplier start, Supplier end) {
super();
this.startSupplier = start;
@@ -39,10 +65,10 @@ public Line(Supplier start, Supplier end) {
}
/**
- * Calculates points along a line from start to end with the given density.
+ * Calculates points along a line from start to end with the given density,
+ * adding them directly to the provided list.
*/
- public static Set calculateLine(Vector3d start, Vector3d end, double density) {
- Set points = new LinkedHashSet<>();
+ public static void calculateLine(List points, Vector3d start, Vector3d end, double density) {
Vector3d direction = new Vector3d(end).sub(start);
double length = direction.length();
double step = length / Math.round(length / density);
@@ -50,71 +76,145 @@ public static Set calculateLine(Vector3d start, Vector3d end, double d
Vector3d current = new Vector3d(start);
int count = (int) (length / step);
+ if (points instanceof ArrayList> al)
+ al.ensureCapacity(points.size() + count + 1);
for (int i = 0; i <= count; i++) {
points.add(new Vector3d(current));
current.add(direction);
}
- return points;
}
/**
- * Connects a list of points with lines, returning all intermediate points.
+ * Connects a list of control points with lines, adding all intermediate points
+ * directly to the provided list. Duplicate points at junctions are removed.
*/
- public static Set connectPoints(List points, double density) {
- Set connectedPoints = new LinkedHashSet<>();
- for (int i = 0; i < points.size() - 1; i++) {
- connectedPoints.addAll(calculateLine(points.get(i), points.get(i + 1), density));
+ public static void connectPoints(List result, List controlPoints, double density) {
+ for (int i = 0; i < controlPoints.size() - 1; i++) {
+ int before = result.size();
+ calculateLine(result, controlPoints.get(i), controlPoints.get(i + 1), density);
+ // Remove duplicate junction point (first point of non-first segments)
+ if (i > 0 && result.size() > before) {
+ result.remove(before);
+ }
}
- return connectedPoints;
}
+ /**
+ * Generates outline points along the line from start to end at the given density.
+ * Delegates to {@link #calculateLine(List, Vector3d, Vector3d, double)}.
+ *
+ * @param points the list to which sampled points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(calculateLine(getStart(), getEnd(), density));
+ public void generateOutline(List points, double density) {
+ calculateLine(points, getStart(), getEnd(), density);
}
+ /**
+ * Returns the current start position by invoking the start supplier.
+ *
+ * @return the start endpoint vector (a fresh copy for dynamic lines)
+ */
public Vector3d getStart() {
return startSupplier.get();
}
+ /**
+ * Sets a new static start endpoint. The vector is copied internally so subsequent
+ * external mutations do not affect this shape. Invalidates the point cache.
+ *
+ * @param start the new start position
+ */
public void setStart(Vector3d start) {
final Vector3d s = new Vector3d(start);
this.startSupplier = () -> new Vector3d(s);
invalidate();
}
+ /**
+ * Returns the current end position by invoking the end supplier.
+ *
+ * @return the end endpoint vector (a fresh copy for dynamic lines)
+ */
public Vector3d getEnd() {
return endSupplier.get();
}
+ /**
+ * Sets a new static end endpoint. The vector is copied internally so subsequent
+ * external mutations do not affect this shape. Invalidates the point cache.
+ *
+ * @param end the new end position
+ */
public void setEnd(Vector3d end) {
final Vector3d e = new Vector3d(end);
this.endSupplier = () -> new Vector3d(e);
invalidate();
}
+ /**
+ * Returns the raw supplier used to resolve the start position each frame.
+ *
+ * @return the start {@link Supplier}
+ */
public Supplier getStartSupplier() {
return startSupplier;
}
+ /**
+ * Replaces the start supplier without invalidating the point cache.
+ * Intended for dynamic-line bookkeeping; prefer {@link #setStart(Vector3d)} for
+ * static updates that should trigger re-sampling.
+ *
+ * @param startSupplier the new start supplier
+ */
public void setStartSupplier(Supplier startSupplier) {
this.startSupplier = startSupplier;
}
+ /**
+ * Returns the raw supplier used to resolve the end position each frame.
+ *
+ * @return the end {@link Supplier}
+ */
public Supplier getEndSupplier() {
return endSupplier;
}
+ /**
+ * Replaces the end supplier without invalidating the point cache.
+ * Intended for dynamic-line bookkeeping; prefer {@link #setEnd(Vector3d)} for
+ * static updates that should trigger re-sampling.
+ *
+ * @param endSupplier the new end supplier
+ */
public void setEndSupplier(Supplier endSupplier) {
this.endSupplier = endSupplier;
}
+ /**
+ * Computes the inter-point density required to distribute {@code targetPointCount} points
+ * evenly along the line. Density equals {@code length / targetPointCount}.
+ *
+ * @param style ignored; a line has no surface or fill variant
+ * @param targetPointCount the desired number of sampled points
+ * @return the spacing between consecutive points
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(targetPointCount, 1);
return new Vector3d(getEnd()).sub(getStart()).length() / count;
}
+ /**
+ * Returns {@code true} if {@code point} lies on the line segment within {@link #EPSILON}
+ * tolerance. Uses parametric projection: {@code t = (point-start)·(end-start) / |end-start|²},
+ * then checks that the closest point on the segment equals {@code point}.
+ *
+ * @param point the point to test
+ * @return {@code true} if the point is on the segment
+ */
@Override
public boolean contains(Vector3d point) {
Vector3d start = getStart();
@@ -129,11 +229,22 @@ public boolean contains(Vector3d point) {
return point.distance(closest) <= EPSILON;
}
+ /**
+ * Returns the Euclidean distance between the start and end endpoints.
+ *
+ * @return the length of the line segment
+ */
@Override
public double getLength() {
return new Vector3d(getStart()).sub(getEnd()).length();
}
+ /**
+ * Rescales the line so its total length equals {@code length} by moving the end endpoint
+ * along the current direction while keeping the start fixed. Invalidates the point cache.
+ *
+ * @param length the new length; clamped to at least {@link Shape#EPSILON}
+ */
@Override
public void setLength(double length) {
length = Math.max(length, Shape.EPSILON);
@@ -144,18 +255,44 @@ public void setLength(double length) {
setEnd(newEnd);
}
+ /**
+ * Always returns {@code 0}; a line has no width dimension.
+ *
+ * @return {@code 0}
+ */
@Override
public double getWidth() { return 0; }
+ /**
+ * No-op; a line has no width dimension.
+ *
+ * @param width ignored
+ */
@Override
public void setWidth(double width) { }
+ /**
+ * Always returns {@code 0}; a line has no height dimension.
+ *
+ * @return {@code 0}
+ */
@Override
public double getHeight() { return 0; }
+ /**
+ * No-op; a line has no height dimension.
+ *
+ * @param height ignored
+ */
@Override
public void setHeight(double height) { }
+ /**
+ * Returns a deep copy of this line, preserving dynamic vs. static endpoint mode and all
+ * inherited {@link AbstractShape} state.
+ *
+ * @return a new {@link Line} with identical configuration
+ */
@Override
public Shape clone() {
Line clone;
@@ -167,6 +304,11 @@ public Shape clone() {
return this.copyTo(clone);
}
+ /**
+ * Returns a human-readable description of this line including its start and end positions.
+ *
+ * @return a string of the form {@code "Line from to "}
+ */
public String toString() {
return "Line from " + getStart() + " to " + getEnd();
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/PolyShape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/PolyShape.java
index 51a75f9..f0f38f9 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/PolyShape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/PolyShape.java
@@ -4,8 +4,37 @@
* Represents a shape that has a number of sides and a side length.
*/
public interface PolyShape extends Shape {
+
+ /**
+ * Returns the number of sides (faces or edges) of this polygonal shape.
+ * Must be at least {@code 3} for a valid polygon.
+ *
+ * @return the number of sides
+ */
int getSides();
+
+ /**
+ * Sets the number of sides for this polygonal shape.
+ * Values below the minimum supported by the implementation should be clamped or rejected.
+ *
+ * @param sides the new number of sides; must be {@code >= 3}
+ */
void setSides(int sides);
+
+ /**
+ * Returns the length of each side of this polygonal shape, in local units.
+ * The circumradius of a regular n-gon with side length {@code s} is
+ * {@code r = s / (2 * sin(π / n))}.
+ *
+ * @return the side length, in local units
+ */
double getSideLength();
+
+ /**
+ * Sets the side length for this polygonal shape.
+ * Changing the side length typically invalidates cached geometry.
+ *
+ * @param sideLength the new side length, in local units; must be {@code > 0}
+ */
void setSideLength(double sideLength);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RadialShape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RadialShape.java
index 3ffa825..78357a3 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RadialShape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RadialShape.java
@@ -5,6 +5,20 @@
* The radius must be greater than 0.
*/
public interface RadialShape extends Shape {
+
+ /**
+ * Returns the radius of this shape.
+ * The radius is always positive (implementations should enforce {@code radius > 0}).
+ *
+ * @return the radius, in the shape's local units
+ */
double getRadius();
+
+ /**
+ * Sets the radius of this shape. The radius must be greater than {@code 0}; values at or
+ * below zero should be clamped or rejected by the implementing class.
+ *
+ * @param radius the new radius, in the shape's local units
+ */
void setRadius(double radius);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Rectangle.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Rectangle.java
index 03f8555..0218cc7 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Rectangle.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Rectangle.java
@@ -3,12 +3,23 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.Set;
+import java.util.List;
import java.util.function.Supplier;
/**
- * A rectangle shape, defined by a plane and dimensions.
- * For dynamic (entity-following) rectangles, use the plugin-side DynamicRectangle wrapper.
+ * A flat rectangular shape aligned to one of the three axis-aligned planes (XZ, XY, or YZ).
+ * The rectangle is centred at the origin of the shape's local coordinate space; an optional
+ * centre offset is applied when corners are supplied, shifting the origin to the midpoint of
+ * the two corners.
+ *
+ * Implements {@link LWHShape} where {@code length} and {@code width} describe the two in-plane
+ * dimensions; {@code height} is always {@code 0}. The orientation plane is chosen via the
+ * {@link Plane} enum.
+ *
+ *
+ * For entity-following rectangles whose corners track moving locations, use the plugin-side
+ * {@code DynamicRectangle} wrapper rather than the supplier constructor directly.
+ *
*/
public class Rectangle extends AbstractShape implements LWHShape {
@@ -21,6 +32,13 @@ public class Rectangle extends AbstractShape implements LWHShape {
private Supplier cornerASupplier;
private Supplier cornerBSupplier;
+ /**
+ * Constructs a rectangle centred at the local origin with explicit dimensions.
+ *
+ * @param length the extent along the first axis of {@code plane}; clamped to at least {@link Shape#EPSILON}
+ * @param width the extent along the second axis of {@code plane}; clamped to at least {@link Shape#EPSILON}
+ * @param plane the axis-aligned plane in which the rectangle lies
+ */
public Rectangle(double length, double width, Plane plane) {
super();
this.plane = plane;
@@ -28,6 +46,16 @@ public Rectangle(double length, double width, Plane plane) {
this.halfWidth = Math.max(width / 2, Shape.EPSILON);
}
+ /**
+ * Constructs a static rectangle from two opposite corner positions.
+ * The centre offset is computed from the midpoint of the two corners (projected onto
+ * {@code plane} so the off-axis component is zeroed).
+ *
+ * @param cornerA the first corner of the rectangle
+ * @param cornerB the diagonally opposite corner; must differ from {@code cornerA}
+ * @param plane the axis-aligned plane in which the rectangle lies
+ * @throws IllegalArgumentException if {@code cornerA} equals {@code cornerB}
+ */
public Rectangle(Vector3d cornerA, Vector3d cornerB, Plane plane) {
super();
if (cornerA.equals(cornerB))
@@ -42,6 +70,15 @@ public Rectangle(Vector3d cornerA, Vector3d cornerB, Plane plane) {
}
}
+ /**
+ * Constructs a dynamic rectangle whose corners are resolved each frame via suppliers.
+ * The shape is marked as {@link AbstractShape#isDynamic() dynamic} so points are regenerated
+ * every draw call. The initial dimensions are computed from the suppliers' first values.
+ *
+ * @param cornerA a supplier providing the current position of the first corner
+ * @param cornerB a supplier providing the current position of the diagonally opposite corner
+ * @param plane the axis-aligned plane in which the rectangle lies
+ */
public Rectangle(Supplier cornerA, Supplier cornerB, Plane plane) {
super();
this.plane = plane;
@@ -79,6 +116,13 @@ private void calculateSteps(double density) {
widthStep = 2 * halfLength / Math.round(2 * halfLength / density);
}
+ /**
+ * Called before each sampling pass. If dynamic corner suppliers are present, refreshes the
+ * half-length and half-width from their current values. Then pre-computes the per-axis step
+ * sizes used by {@link #generateOutline} and {@link #generateSurface}.
+ *
+ * @param density the target spacing between sampled points
+ */
@Override
public void beforeSampling(double density) {
if (cornerASupplier != null && cornerBSupplier != null) {
@@ -89,13 +133,27 @@ public void beforeSampling(double density) {
calculateSteps(density);
}
+ /**
+ * Called after each sampling pass to apply the centre offset to all generated points.
+ * This translates the points (which are generated relative to the local origin) into the
+ * correct world position when the rectangle was constructed from corners.
+ *
+ * @param points the list of sampled points to translate in-place
+ */
@Override
- public void afterSampling(Set points) {
+ public void afterSampling(List points) {
points.forEach(vector -> vector.add(centerOffset));
}
+ /**
+ * Generates outline (perimeter) points for the rectangle. Points are placed along all four
+ * edges at the step sizes calculated in {@link #beforeSampling}.
+ *
+ * @param points the list to which outline points are appended
+ * @param density the approximate spacing between consecutive points (used implicitly via step sizes)
+ */
@Override
- public void generateOutline(Set points, double density) {
+ public void generateOutline(List points, double density) {
for (double l = -halfLength + widthStep; l < halfLength; l += widthStep) {
points.add(vectorFromLengthWidth(l, -halfWidth));
points.add(vectorFromLengthWidth(l, halfWidth));
@@ -106,8 +164,15 @@ public void generateOutline(Set points, double density) {
}
}
+ /**
+ * Generates surface (filled plane) points for the rectangle by sampling a uniform grid
+ * across both in-plane axes.
+ *
+ * @param points the list to which surface points are appended
+ * @param density the approximate spacing between consecutive points (used implicitly via step sizes)
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
for (double w = -halfWidth; w <= halfWidth; w += lengthStep) {
for (double l = -halfLength; l <= halfLength; l += widthStep) {
points.add(vectorFromLengthWidth(l, w));
@@ -115,6 +180,14 @@ public void generateSurface(Set points, double density) {
}
}
+ /**
+ * Computes the inter-point density needed to reach {@code targetPointCount} sampled points.
+ * For SURFACE/FILL: {@code sqrt(area / count)}. For OUTLINE: {@code perimeter / count}.
+ *
+ * @param style the sampling style that determines which formula is used
+ * @param targetPointCount the desired number of points
+ * @return the computed density
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(targetPointCount, 1);
@@ -124,6 +197,13 @@ public double computeDensity(SamplingStyle style, int targetPointCount) {
};
}
+ /**
+ * Returns {@code true} if {@code point} lies within (or on the boundary of) the rectangle,
+ * including the constraint that it lies on the rectangle's plane (within {@link #EPSILON}).
+ *
+ * @param point the point to test in local coordinates
+ * @return {@code true} if the point is inside the rectangle
+ */
@Override
public boolean contains(Vector3d point) {
return switch (plane) {
@@ -133,40 +213,99 @@ public boolean contains(Vector3d point) {
};
}
+ /**
+ * Returns the full length of the rectangle (twice the internal half-length).
+ *
+ * @return the length along the first in-plane axis
+ */
@Override
public double getLength() { return halfLength * 2; }
+ /**
+ * Sets the rectangle's length and invalidates the point cache.
+ *
+ * @param length the new full length; clamped to at least {@link Shape#EPSILON}
+ */
@Override
public void setLength(double length) {
this.halfLength = Math.max(length / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Returns the full width of the rectangle (twice the internal half-width).
+ *
+ * @return the width along the second in-plane axis
+ */
@Override
public double getWidth() { return halfWidth * 2; }
+ /**
+ * Sets the rectangle's width and invalidates the point cache.
+ *
+ * @param width the new full width; clamped to at least {@link Shape#EPSILON}
+ */
@Override
public void setWidth(double width) {
this.halfWidth = Math.max(width / 2, Shape.EPSILON);
invalidate();
}
+ /**
+ * Always returns {@code 0}; a rectangle has no height dimension.
+ *
+ * @return {@code 0}
+ */
@Override
public double getHeight() { return 0; }
+ /**
+ * No-op; a rectangle has no height dimension.
+ *
+ * @param height ignored
+ */
@Override
public void setHeight(double height) { }
+ /**
+ * Returns the axis-aligned {@link Plane} in which this rectangle lies.
+ *
+ * @return the current plane
+ */
public Plane getPlane() { return plane; }
+ /**
+ * Sets the axis-aligned plane and invalidates the point cache.
+ *
+ * @param plane the new plane orientation
+ */
public void setPlane(Plane plane) {
this.plane = plane;
invalidate();
}
+ /**
+ * Returns the supplier used to resolve the first corner for dynamic rectangles, or
+ * {@code null} if the rectangle was constructed from static dimensions or corners.
+ *
+ * @return the corner A supplier, or {@code null}
+ */
public Supplier getCornerASupplier() { return cornerASupplier; }
+
+ /**
+ * Returns the supplier used to resolve the second corner for dynamic rectangles, or
+ * {@code null} if the rectangle was constructed from static dimensions or corners.
+ *
+ * @return the corner B supplier, or {@code null}
+ */
public Supplier getCornerBSupplier() { return cornerBSupplier; }
+ /**
+ * Returns a deep copy of this rectangle, preserving dynamic vs. static corner mode, the
+ * centre offset, and all inherited {@link AbstractShape} state.
+ *
+ * @return a new {@link Rectangle} with identical configuration
+ */
@Override
public Shape clone() {
Rectangle rectangle;
@@ -179,13 +318,37 @@ public Shape clone() {
return this.copyTo(rectangle);
}
+ /**
+ * Returns a human-readable description including the plane name, length, and width.
+ *
+ * @return a string of the form {@code " rectangle with length and width "}
+ */
@Override
public String toString() {
String axis = this.plane.toString().toLowerCase();
return axis + " rectangle with length " + this.getLength() + " and width " + this.getWidth();
}
+ /**
+ * Identifies which of the three axis-aligned planes a {@link Rectangle} lies in.
+ *
+ * - {@link #XZ} - the horizontal ground plane (Y = 0)
+ * - {@link #XY} - the vertical frontal plane (Z = 0)
+ * - {@link #YZ} - the vertical lateral plane (X = 0)
+ *
+ */
public enum Plane {
- XZ, XY, YZ
+ /**
+ * The horizontal XZ plane; length along X, width along Z.
+ */
+ XZ,
+ /**
+ * The vertical XY plane; length along X, width along Y.
+ */
+ XY,
+ /**
+ * The vertical YZ plane; length along Y, width along Z.
+ */
+ YZ
}
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolygon.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolygon.java
index 87705b3..e618eac 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolygon.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolygon.java
@@ -4,27 +4,71 @@
import com.sovdee.shapes.util.VectorUtil;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
-
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A regular polygon (or regular prism when {@code height > 0}) with equal-length sides and equal
+ * interior angles, oriented in the XZ plane. The circumscribed circle has the given {@code radius},
+ * and each side subtends an angle of {@code 2π / sides} at the centre.
+ *
+ * When {@code height == 0} the shape is a flat polygon; when {@code height > 0} it becomes a
+ * prism extruded along the Y axis. The shape implements {@link PolyShape}, {@link RadialShape},
+ * and {@link LWHShape}; {@code length} and {@code width} from {@link LWHShape} always return
+ * {@code 0} for this shape — only {@code height} is meaningful.
+ *
+ *
+ * The minimum number of sides is 3 (triangle); the maximum interior angle per side is
+ * {@code 2π/3} (i.e. a minimum of 3 sides is enforced when setting via angle).
+ *
+ */
public class RegularPolygon extends AbstractShape implements PolyShape, RadialShape, LWHShape {
private double angle;
private double radius;
private double height;
+ /**
+ * Constructs a flat regular polygon (height = 0) with the given number of sides and radius.
+ *
+ * @param sides the number of sides; determines the vertex angle as {@code 2π / sides}
+ * @param radius the circumscribed (outer) radius; clamped to at least {@link Shape#EPSILON}
+ */
public RegularPolygon(int sides, double radius) {
this((Math.PI * 2) / sides, radius, 0);
}
+ /**
+ * Constructs a flat regular polygon (height = 0) with an explicit vertex angle and radius.
+ * The number of sides is derived as {@code floor(2π / angle)}.
+ *
+ * @param angle the angular size of each side in radians; clamped to {@code [ε, 2π/3]}
+ * @param radius the circumscribed (outer) radius; clamped to at least {@link Shape#EPSILON}
+ */
public RegularPolygon(double angle, double radius) {
this(angle, radius, 0);
}
+ /**
+ * Constructs a regular prism with the given number of sides, circumscribed radius, and height.
+ * When {@code height == 0} this is equivalent to {@link #RegularPolygon(int, double)}.
+ *
+ * @param sides the number of sides
+ * @param radius the circumscribed (outer) radius; clamped to at least {@link Shape#EPSILON}
+ * @param height the extrusion height along the Y axis; {@code 0} for a flat polygon
+ */
public RegularPolygon(int sides, double radius, double height) {
this((Math.PI * 2) / sides, radius, height);
}
+ /**
+ * Constructs a regular prism with an explicit vertex angle, radius, and height.
+ * This is the canonical constructor; all other constructors delegate here.
+ *
+ * @param angle the angular size of each side in radians; clamped to {@code [ε, 2π/3]}
+ * @param radius the circumscribed (outer) radius; clamped to at least {@link Shape#EPSILON}
+ * @param height the extrusion height along the Y axis; {@code 0} for a flat polygon
+ */
public RegularPolygon(double angle, double radius, double height) {
super();
this.angle = Math.clamp(angle, Shape.EPSILON, Math.PI * 2 / 3);
@@ -34,10 +78,25 @@ public RegularPolygon(double angle, double radius, double height) {
// --- Static calculation methods ---
- public static Set calculateRegularPolygon(double radius, double angle, double density, boolean wireframe) {
+ /**
+ * Samples points on (or filling) a regular polygon in the XZ plane.
+ *
+ * When {@code wireframe} is {@code true} only the perimeter edges are sampled. When
+ * {@code false}, concentric rings from the outer radius inward are filled (surface mode),
+ * with a centre point added explicitly.
+ *
+ * The apothem is {@code radius * cos(angle/2)}; the radial step between rings is
+ * {@code radius / round(apothem / density)}.
+ *
+ * @param points the list to which sampled points are appended
+ * @param radius the circumscribed radius of the polygon
+ * @param angle the angular size of each side in radians ({@code 2π / sides})
+ * @param density the approximate spacing between consecutive points
+ * @param wireframe {@code true} to sample only the outer perimeter; {@code false} to fill
+ */
+ public static void calculateRegularPolygon(List points, double radius, double angle, double density, boolean wireframe) {
angle = Math.max(angle, Shape.EPSILON);
- Set points = new LinkedHashSet<>();
double apothem = radius * Math.cos(angle / 2);
double radiusStep = radius / Math.round(apothem / density);
if (wireframe) {
@@ -48,63 +107,118 @@ public static Set calculateRegularPolygon(double radius, double angle,
for (double subRadius = radius; subRadius >= 0; subRadius -= radiusStep) {
Vector3d vertex = new Vector3d(subRadius, 0, 0);
for (double i = 0; i < 2 * Math.PI; i += angle) {
- points.addAll(Line.calculateLine(
+ Line.calculateLine(points,
VectorUtil.rotateAroundY(new Vector3d(vertex), i),
VectorUtil.rotateAroundY(new Vector3d(vertex), i + angle),
- density));
+ density);
}
}
- return points;
}
- public static Set calculateRegularPrism(double radius, double angle, double height, double density, boolean wireframe) {
- Set points = new LinkedHashSet<>();
+ /**
+ * Samples points on (or filling) a regular prism extruded along the Y axis.
+ *
+ * For each side of the polygon, the bottom edge is sampled using
+ * {@link Line#calculateLine(List, Vector3d, Vector3d, double)}, then either vertical edge
+ * points are paired (wireframe) or filled columns are generated up to {@code height}.
+ * Vertical pillar edges at each vertex are always included in wireframe mode.
+ *
+ *
+ * @param points the list to which sampled points are appended
+ * @param radius the circumscribed radius of the prism's base
+ * @param angle the angular size of each side in radians ({@code 2π / sides})
+ * @param height the extrusion height along the Y axis
+ * @param density the approximate spacing between consecutive points
+ * @param wireframe {@code true} to sample only the edges; {@code false} to fill the lateral faces
+ */
+ public static void calculateRegularPrism(List points, double radius, double angle, double height, double density, boolean wireframe) {
Vector3d vertex = new Vector3d(radius, 0, 0);
+ // Need a temp list for the edge points since each spawns vertical points
+ List edgePoints = new ArrayList<>();
for (double i = 0; i < 2 * Math.PI; i += angle) {
Vector3d currentVertex = VectorUtil.rotateAroundY(new Vector3d(vertex), i);
- for (Vector3d vector : Line.calculateLine(currentVertex, VectorUtil.rotateAroundY(new Vector3d(vertex), i + angle), density)) {
+ edgePoints.clear();
+ Line.calculateLine(edgePoints, currentVertex, VectorUtil.rotateAroundY(new Vector3d(vertex), i + angle), density);
+ for (Vector3d vector : edgePoints) {
points.add(vector);
if (wireframe) {
points.add(new Vector3d(vector.x, height, vector.z));
} else {
- points.addAll(Line.calculateLine(vector, new Vector3d(vector.x, height, vector.z), density));
+ Line.calculateLine(points, vector, new Vector3d(vector.x, height, vector.z), density);
}
}
if (wireframe)
- points.addAll(Line.calculateLine(currentVertex, new Vector3d(currentVertex.x, height, currentVertex.z), density));
+ Line.calculateLine(points, currentVertex, new Vector3d(currentVertex.x, height, currentVertex.z), density);
}
- return points;
}
// --- Generation methods ---
+ /**
+ * Generates outline (perimeter) points. For flat polygons this is the outer edge; for prisms
+ * this includes both the top/bottom edges and the vertical pillar edges at each vertex.
+ *
+ * @param points the list to which outline points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateOutline(Set points, double density) {
+ public void generateOutline(List points, double density) {
if (height == 0)
- points.addAll(calculateRegularPolygon(this.radius, this.angle, density, true));
+ calculateRegularPolygon(points, this.radius, this.angle, density, true);
else
- points.addAll(calculateRegularPrism(this.radius, this.angle, this.height, density, true));
+ calculateRegularPrism(points, this.radius, this.angle, this.height, density, true);
}
+ /**
+ * Generates surface points. For flat polygons this samples the filled face; for prisms this
+ * samples the lateral faces (not the top/bottom caps).
+ *
+ * @param points the list to which surface points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
if (height == 0)
- points.addAll(calculateRegularPolygon(this.radius, this.angle, density, false));
+ calculateRegularPolygon(points, this.radius, this.angle, density, false);
else
- points.addAll(calculateRegularPrism(this.radius, this.angle, this.height, density, false));
+ calculateRegularPrism(points, this.radius, this.angle, this.height, density, false);
}
+ /**
+ * Generates filled interior points. For flat polygons this is identical to surface mode.
+ * For prisms, a surface polygon is generated and then replicated vertically using
+ * {@link AbstractShape#fillVertically} to fill the volume.
+ *
+ * @param points the list to which filled points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateFilled(Set points, double density) {
+ public void generateFilled(List points, double density) {
if (height == 0)
generateSurface(points, density);
else {
- Set polygon = calculateRegularPolygon(this.radius, this.angle, density, false);
- fillVertically(polygon, height, density);
- points.addAll(polygon);
+ int start = points.size();
+ calculateRegularPolygon(points, this.radius, this.angle, density, false);
+ fillVertically(points, start, height, density);
}
}
+ /**
+ * Computes the density needed to achieve {@code targetPointCount} sampled points.
+ * Uses the following formulas (where {@code s = sides}, {@code r = radius},
+ * {@code θ = angle}, {@code h = height}):
+ *
+ * - OUTLINE (flat): {@code 2·s·r·sin(θ/2) / count}
+ * - OUTLINE (prism): {@code (4·s·r·sin(θ/2) + h·s) / count}
+ * - SURFACE (flat): {@code sqrt(s·r²·sin(θ)/2 / count)}
+ * - SURFACE (prism): {@code (s·r²·sin(θ) + sideLength·s·h) / count}
+ * - FILL: {@code s·r²·sin(θ)·h / count}
+ *
+ *
+ * @param style determines which area/perimeter formula to apply
+ * @param targetPointCount the desired number of points
+ * @return the computed density
+ */
@Override
public double computeDensity(SamplingStyle style, int targetPointCount) {
int count = Math.max(targetPointCount, 1);
@@ -124,6 +238,15 @@ public double computeDensity(SamplingStyle style, int targetPointCount) {
};
}
+ /**
+ * Returns {@code true} if {@code point} lies within the polygon (or prism) in local
+ * coordinates. For flat polygons the point must lie in the XZ plane (|y| < ε). For prisms
+ * the Y coordinate must be in {@code [0, height]}. The XZ containment check uses the apothem
+ * and the angular position to test against the actual polygon boundary.
+ *
+ * @param point the point to test in local coordinates
+ * @return {@code true} if the point is inside the polygon or prism
+ */
@Override
public boolean contains(Vector3d point) {
if (height > 0 && (point.y < 0 || point.y > height)) return false;
@@ -134,18 +257,40 @@ public boolean contains(Vector3d point) {
return dist <= radius && dist <= apothem / Math.cos(Math.atan2(point.z, point.x) % angle - angle / 2);
}
+ /**
+ * Returns the number of sides, computed as {@code floor(2π / angle)}.
+ *
+ * @return the number of sides of this polygon
+ */
@Override
public int getSides() { return (int) (Math.PI * 2 / this.angle); }
+ /**
+ * Sets the number of sides by recomputing the vertex angle as {@code 2π / sides}.
+ * Minimum of 3 sides is enforced. Invalidates the point cache.
+ *
+ * @param sides the desired number of sides; clamped to at least 3
+ */
@Override
public void setSides(int sides) {
this.angle = (Math.PI * 2) / Math.max(sides, 3);
invalidate();
}
+ /**
+ * Returns the edge length of each side, equal to {@code 2 * radius * sin(angle / 2)}.
+ *
+ * @return the side length
+ */
@Override
public double getSideLength() { return this.radius * 2 * Math.sin(this.angle / 2); }
+ /**
+ * Sets the radius so that the edge length equals {@code sideLength}, keeping the current
+ * angle (and thus side count) unchanged. Invalidates the point cache.
+ *
+ * @param sideLength the desired edge length; clamped to at least {@link Shape#EPSILON}
+ */
@Override
public void setSideLength(double sideLength) {
sideLength = Math.max(sideLength, Shape.EPSILON);
@@ -154,41 +299,93 @@ public void setSideLength(double sideLength) {
invalidate();
}
+ /**
+ * Returns the circumscribed (outer) radius of the polygon.
+ *
+ * @return the radius
+ */
@Override
public double getRadius() { return this.radius; }
+ /**
+ * Sets the circumscribed radius and invalidates the point cache.
+ *
+ * @param radius the new radius; clamped to at least {@link Shape#EPSILON}
+ */
@Override
public void setRadius(double radius) {
this.radius = Math.max(radius, Shape.EPSILON);
invalidate();
}
+ /**
+ * Always returns {@code 0}; length is not a distinct dimension of this shape.
+ *
+ * @return {@code 0}
+ */
@Override
public double getLength() { return 0; }
+ /**
+ * No-op; length is not a distinct dimension of this shape.
+ *
+ * @param length ignored
+ */
@Override
public void setLength(double length) { }
+ /**
+ * Always returns {@code 0}; width is not a distinct dimension of this shape.
+ *
+ * @return {@code 0}
+ */
@Override
public double getWidth() { return 0; }
+ /**
+ * No-op; width is not a distinct dimension of this shape.
+ *
+ * @param width ignored
+ */
@Override
public void setWidth(double width) { }
+ /**
+ * Returns the extrusion height of the prism along the Y axis, or {@code 0} for a flat polygon.
+ *
+ * @return the height
+ */
@Override
public double getHeight() { return height; }
+ /**
+ * Sets the extrusion height and invalidates the point cache. A value of {@code 0} produces a
+ * flat polygon; positive values produce a prism.
+ *
+ * @param height the new height; clamped to at least {@code 0}
+ */
@Override
public void setHeight(double height) {
this.height = Math.max(height, 0);
invalidate();
}
+ /**
+ * Returns a deep copy of this polygon, preserving angle, radius, height, and all inherited
+ * {@link AbstractShape} state.
+ *
+ * @return a new {@link RegularPolygon} with identical configuration
+ */
@Override
public Shape clone() {
return this.copyTo(new RegularPolygon(angle, radius, height));
}
+ /**
+ * Returns a human-readable description of this polygon including side count and radius.
+ *
+ * @return a string of the form {@code "regular polygon with sides and radius "}
+ */
@Override
public String toString() {
return "regular polygon with " + getSides() + " sides and radius " + getRadius();
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolyhedron.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolyhedron.java
index ec0042b..69d0696 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolyhedron.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/RegularPolyhedron.java
@@ -4,10 +4,23 @@
import org.joml.Quaterniond;
import org.joml.Vector3d;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.Set;
+import java.util.List;
+/**
+ * One of the five Platonic solids (regular convex polyhedra), specifically the subset with
+ * triangular or pentagonal faces: tetrahedron (4 faces), octahedron (8 faces), dodecahedron
+ * (12 faces), and icosahedron (20 faces). The shape is centred at the origin and scaled so that
+ * the circumscribed sphere has the given {@code radius}.
+ *
+ * Each face is generated independently using pre-computed quaternion rotations ({@code TETRAHEDRON_FACES},
+ * etc.) that orient a canonical face into its correct position. Surface and outline points per face
+ * are produced via {@link RegularPolygon#calculateRegularPolygon}.
+ *
+ *
+ * The {@link #contains(Vector3d)} check uses a conservative inscribed-sphere test rather than an
+ * exact half-plane test.
+ *
+ */
public class RegularPolyhedron extends AbstractShape implements RadialShape, PolyShape {
private static final Quaterniond[] TETRAHEDRON_FACES = {
@@ -72,6 +85,13 @@ public class RegularPolyhedron extends AbstractShape implements RadialShape, Pol
private double radius;
private int faces;
+ /**
+ * Constructs a regular polyhedron with the given circumscribed radius and face count.
+ * Only face counts of 4, 8, 12, and 20 are valid; any other value defaults to 4 (tetrahedron).
+ *
+ * @param radius the circumscribed sphere radius; clamped to at least {@link Shape#EPSILON}
+ * @param faces the number of faces; must be 4, 8, 12, or 20 (defaults to 4 otherwise)
+ */
public RegularPolyhedron(double radius, int faces) {
super();
this.radius = Math.max(radius, Shape.EPSILON);
@@ -81,45 +101,62 @@ public RegularPolyhedron(double radius, int faces) {
};
}
+ /**
+ * Generates wireframe (edge) outline points for each face of the polyhedron by applying
+ * the face-specific quaternion rotation to outline points of a regular polygon face.
+ *
+ * @param points the list to which outline points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateOutline(Set points, double density) {
- points.addAll(switch (faces) {
- case 4 -> generatePolyhedron(TETRAHEDRON_FACES, radius, density, SamplingStyle.OUTLINE);
- case 8 -> generatePolyhedron(OCTAHEDRON_FACES, radius, density, SamplingStyle.OUTLINE);
- case 20 -> generatePolyhedron(ICOSAHEDRON_FACES, radius, density, SamplingStyle.OUTLINE);
- case 12 -> generatePolyhedron(DODECAHEDRON_FACES, radius, density, SamplingStyle.OUTLINE);
- default -> new HashSet<>();
- });
+ public void generateOutline(List points, double density) {
+ Quaterniond[] rotations = getFaceRotations();
+ if (rotations != null)
+ generatePolyhedron(points, rotations, radius, density, SamplingStyle.OUTLINE);
}
+ /**
+ * Generates surface points covering all faces of the polyhedron. Each face is filled
+ * with a concentric-ring polygon pattern and then rotated into place.
+ *
+ * @param points the list to which surface points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateSurface(Set points, double density) {
- points.addAll(switch (faces) {
- case 4 -> generatePolyhedron(TETRAHEDRON_FACES, radius, density, SamplingStyle.SURFACE);
- case 8 -> generatePolyhedron(OCTAHEDRON_FACES, radius, density, SamplingStyle.SURFACE);
- case 20 -> generatePolyhedron(ICOSAHEDRON_FACES, radius, density, SamplingStyle.SURFACE);
- case 12 -> generatePolyhedron(DODECAHEDRON_FACES, radius, density, SamplingStyle.SURFACE);
- default -> new HashSet<>();
- });
+ public void generateSurface(List points, double density) {
+ Quaterniond[] rotations = getFaceRotations();
+ if (rotations != null)
+ generatePolyhedron(points, rotations, radius, density, SamplingStyle.SURFACE);
}
+ /**
+ * Generates filled interior points by stacking concentric scaled copies of the surface from
+ * {@code radius} down to {@code 0} in steps of {@code radius / round(radius / density)}.
+ *
+ * @param points the list to which filled points are appended
+ * @param density the approximate spacing between consecutive points
+ */
@Override
- public void generateFilled(Set points, double density) {
+ public void generateFilled(List points, double density) {
double step = radius / Math.round(radius / density);
- Quaterniond[] rotations = switch (faces) {
+ Quaterniond[] rotations = getFaceRotations();
+ if (rotations == null) return;
+ for (double i = radius; i > 0; i -= step) {
+ generatePolyhedron(points, rotations, i, density, SamplingStyle.SURFACE);
+ }
+ }
+
+ private Quaterniond[] getFaceRotations() {
+ return switch (faces) {
case 4 -> TETRAHEDRON_FACES;
case 8 -> OCTAHEDRON_FACES;
case 12 -> DODECAHEDRON_FACES;
case 20 -> ICOSAHEDRON_FACES;
- default -> new Quaterniond[0];
+ default -> null;
};
- for (double i = radius; i > 0; i -= step) {
- points.addAll(generatePolyhedron(rotations, i, density, SamplingStyle.SURFACE));
- }
}
- private Set generatePolyhedron(Quaterniond[] rotations, double radius, double density, SamplingStyle style) {
- Set points = new LinkedHashSet<>();
+ private void generatePolyhedron(List points, Quaterniond[] rotations, double radius, double density, SamplingStyle style) {
int sides = this.faces == 12 ? 5 : 3;
double sideLength = switch (faces) {
case 4 -> radius / TETRA_R2SL;
@@ -138,29 +175,24 @@ private Set generatePolyhedron(Quaterniond[] rotations, double radius,
Vector3d offset = new Vector3d(0, inscribedRadius, 0);
double faceRadius = sideLength / (2 * Math.sin(Math.PI / sides));
for (Quaterniond rotation : rotations) {
- Set facePoints = new LinkedHashSet<>(switch (style) {
- case OUTLINE -> generateFaceOutline(sides, faceRadius, density);
- case FILL, SURFACE -> generateFaceSurface(sides, faceRadius, density);
- });
- facePoints.forEach(point -> rotation.transform(point.add(offset)));
- points.addAll(facePoints);
+ int faceStart = points.size();
+ switch (style) {
+ case OUTLINE -> RegularPolygon.calculateRegularPolygon(points, faceRadius, 2 * Math.PI / sides, density, true);
+ case FILL, SURFACE -> generateFaceSurface(points, sides, faceRadius, density);
+ }
+ for (int i = faceStart; i < points.size(); i++) {
+ rotation.transform(points.get(i).add(offset));
+ }
}
- return points;
- }
-
- private Set generateFaceOutline(int sides, double radius, double density) {
- return new LinkedHashSet<>(RegularPolygon.calculateRegularPolygon(radius, 2 * Math.PI / sides, density, true));
}
- private Set generateFaceSurface(int sides, double radius, double density) {
- Set facePoints = new LinkedHashSet<>();
+ private void generateFaceSurface(List points, int sides, double radius, double density) {
double apothem = radius * Math.cos(Math.PI / sides);
double radiusStep = radius / Math.round(apothem / density);
for (double subRadius = radius; subRadius > 0; subRadius -= radiusStep) {
- facePoints.addAll(RegularPolygon.calculateRegularPolygon(subRadius, 2 * Math.PI / sides, density, false));
+ RegularPolygon.calculateRegularPolygon(points, subRadius, 2 * Math.PI / sides, density, false);
}
- facePoints.add(new Vector3d(0, 0, 0));
- return facePoints;
+ points.add(new Vector3d(0, 0, 0));
}
@Override
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Shape.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Shape.java
index 194910c..d3238d3 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Shape.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Shape.java
@@ -1,11 +1,13 @@
package com.sovdee.shapes.shapes;
+import com.sovdee.shapes.modifiers.PointContext;
import com.sovdee.shapes.sampling.PointSampler;
import com.sovdee.shapes.sampling.SamplingStyle;
+import com.sovdee.shapes.sampling.ShapeRenderer;
import org.joml.Quaterniond;
import org.joml.Vector3d;
-import java.util.Set;
+import java.util.List;
/**
* Represents a geometric shape. Pure geometry interface — sampling/caching/drawing
@@ -17,39 +19,154 @@ public interface Shape extends Cloneable {
// --- Spatial transform ---
+ /**
+ * Returns a copy of this shape's orientation quaternion.
+ * The orientation describes the rotation applied to all generated points before they are
+ * returned to the caller; it does not affect the shape's local geometry.
+ *
+ * @return a defensive copy of the current {@link Quaterniond} orientation
+ */
Quaterniond getOrientation();
+
+ /**
+ * Sets the orientation of this shape.
+ * The provided quaternion is copied, so subsequent mutation of the argument has no effect.
+ *
+ * @param orientation the new orientation quaternion; must not be null
+ */
void setOrientation(Quaterniond orientation);
+ /**
+ * Returns the uniform scale factor applied to this shape's generated points.
+ * A value of {@code 1.0} means no scaling; values greater than {@code 1.0} enlarge the shape.
+ *
+ * @return the current scale factor
+ */
double getScale();
+
+ /**
+ * Sets the uniform scale factor for this shape.
+ * The scale is applied to every generated point relative to the shape's local origin.
+ *
+ * @param scale the new scale factor; typically positive
+ */
void setScale(double scale);
+ /**
+ * Returns a copy of this shape's positional offset vector.
+ * The offset is added to every generated point after orientation and scale are applied,
+ * shifting the entire shape in local space.
+ *
+ * @return a defensive copy of the current offset {@link Vector3d}
+ */
Vector3d getOffset();
+
+ /**
+ * Sets the positional offset applied to this shape's generated points.
+ *
+ * @param offset the new offset vector; must not be null
+ */
void setOffset(Vector3d offset);
// --- Oriented axes ---
+ /**
+ * Returns the world-space direction of this shape's local X axis after applying the current orientation.
+ * Equivalent to rotating {@code (1, 0, 0)} by {@link #getOrientation()}.
+ *
+ * @return the transformed X axis as a unit {@link Vector3d}
+ */
Vector3d getRelativeXAxis();
+
+ /**
+ * Returns the world-space direction of this shape's local Y axis after applying the current orientation.
+ * Equivalent to rotating {@code (0, 1, 0)} by {@link #getOrientation()}.
+ *
+ * @return the transformed Y axis as a unit {@link Vector3d}
+ */
Vector3d getRelativeYAxis();
+
+ /**
+ * Returns the world-space direction of this shape's local Z axis after applying the current orientation.
+ * Equivalent to rotating {@code (0, 0, 1)} by {@link #getOrientation()}.
+ *
+ * @return the transformed Z axis as a unit {@link Vector3d}
+ */
Vector3d getRelativeZAxis();
// --- Geometry query ---
+ /**
+ * Returns {@code true} if the given point lies within or on the surface of this shape's volume.
+ * Points are tested in the shape's local coordinate space (before orientation and scale are applied).
+ * Implementations should use {@link #EPSILON} for floating-point boundary comparisons.
+ *
+ * @param point the point to test, in local coordinates
+ * @return {@code true} if the point is contained by this shape
+ */
boolean contains(Vector3d point);
// --- Change detection ---
+ /**
+ * Returns a monotonically increasing version counter that is incremented each time the shape's
+ * geometry changes (e.g. when a radius or height setter is called). {@link PointSampler}
+ * implementations use this value to detect stale caches without performing a deep comparison.
+ *
+ * @return the current version number; starts at {@code 0} and only increases
+ */
long getVersion();
// --- Dynamic support ---
+ /**
+ * Returns {@code true} if this shape is dynamic, meaning its geometry may change between
+ * render frames (e.g. a Bezier curve driven by a supplier of live locations).
+ * Dynamic shapes bypass the point cache and re-generate points every frame.
+ *
+ * @return {@code true} if this shape is marked dynamic
+ */
boolean isDynamic();
+
+ /**
+ * Marks this shape as dynamic or static. Dynamic shapes skip the {@link PointSampler}
+ * cache and regenerate points on every render call.
+ *
+ * @param dynamic {@code true} to enable dynamic mode; {@code false} to enable caching
+ */
void setDynamic(boolean dynamic);
// --- Point generation (density as parameter) ---
- void generateOutline(Set points, double density);
- void generateSurface(Set points, double density);
- void generateFilled(Set points, double density);
+ /**
+ * Generates points that trace the wireframe outline of this shape and appends them to {@code points}.
+ * For most shapes this produces one or more closed loops of evenly spaced points.
+ * The approximate arc-length distance between consecutive points equals {@code density}.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points; smaller values produce more points
+ */
+ void generateOutline(List points, double density);
+
+ /**
+ * Generates points that cover the surface of this shape and appends them to {@code points}.
+ * The surface is the outermost shell, distinct from the filled interior.
+ * Falls back to {@link #generateOutline} in {@link AbstractShape} unless overridden.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points
+ */
+ void generateSurface(List points, double density);
+
+ /**
+ * Generates points that fill the volume of this shape and appends them to {@code points}.
+ * The result includes both the surface and interior points.
+ * Falls back to {@link #generateSurface} in {@link AbstractShape} unless overridden.
+ *
+ * @param points the list to append generated points to; must not be null
+ * @param density the desired spacing between points; smaller values produce denser fills
+ */
+ void generateFilled(List points, double density);
/**
* Called by PointSampler before point generation. Override for supplier refresh, step recalc, etc.
@@ -59,7 +176,7 @@ default void beforeSampling(double density) {}
/**
* Called by PointSampler after point generation. Override for centerOffset adjustment, etc.
*/
- default void afterSampling(Set points) {}
+ default void afterSampling(List points) {}
/**
* Computes the density needed to achieve approximately the given number of points.
@@ -68,11 +185,47 @@ default void afterSampling(Set points) {}
// --- PointSampler ---
+ /**
+ * Returns the {@link PointSampler} responsible for caching, ordering, and modifying this
+ * shape's generated points. Each shape owns exactly one sampler.
+ *
+ * @return the current {@link PointSampler}; never null after construction
+ */
PointSampler getPointSampler();
+
+ /**
+ * Replaces the {@link PointSampler} used by this shape.
+ * The new sampler takes over all caching and render-modifier management immediately.
+ *
+ * @param sampler the new sampler to use; must not be null
+ */
void setPointSampler(PointSampler sampler);
+ /**
+ * Convenience default: drives the full render loop using the shape's own orientation.
+ */
+ default void render(Quaterniond orientation, ShapeRenderer> renderer) {
+ getPointSampler().render(this, orientation, renderer);
+ }
+
// --- Replication ---
+ /**
+ * Creates and returns a deep copy of this shape, including its spatial transform (orientation,
+ * scale, offset), dynamic flag, and point sampler.
+ * Implementations must return the most specific concrete type possible.
+ *
+ * @return a new, independent {@link Shape} with identical state
+ */
Shape clone();
+
+ /**
+ * Copies this shape's base state (orientation, scale, offset, dynamic flag, point sampler)
+ * into {@code target} and returns {@code target}. Dimension-specific fields are the
+ * responsibility of each concrete class's own {@link #clone()} implementation.
+ *
+ * @param target the shape to copy state into; must not be null
+ * @return {@code target}, for chaining
+ */
Shape copyTo(Shape target);
}
diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Sphere.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Sphere.java
index b1b9bb6..12afaa9 100644
--- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Sphere.java
+++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Sphere.java
@@ -3,8 +3,8 @@
import com.sovdee.shapes.sampling.SamplingStyle;
import org.joml.Vector3d;
-import java.util.LinkedHashSet;
-import java.util.Set;
+import java.util.ArrayList;
+import java.util.List;
public class Sphere extends AbstractShape implements RadialShape {
@@ -33,12 +33,11 @@ public Sphere(double radius) {
// --- Static calculation methods ---
- private static Set calculateFibonacciSphere(int pointCount, double radius) {
- return calculateFibonacciSphere(pointCount, radius, Math.PI);
+ private static void calculateFibonacciSphere(List points, int pointCount, double radius) {
+ calculateFibonacciSphere(points, pointCount, radius, Math.PI);
}
- private static Set calculateFibonacciSphere(int pointCount, double radius, double angleCutoff) {
- Set points = new LinkedHashSet<>();
+ private static void calculateFibonacciSphere(List points, int pointCount, double radius, double angleCutoff) {
double y = 1;
if (angleCutoff > Math.PI) angleCutoff = Math.PI;
double yLimit = Math.cos(angleCutoff);
@@ -50,7 +49,7 @@ private static Set calculateFibonacciSphere(int pointCount, double rad
points.add(new Vector3d(r * SPHERE_THETA_COS[i], y * radius, r * SPHERE_THETA_SIN[i]));
y -= yStep;
if (y <= yLimit) {
- return points;
+ return;
}
}
if (pointCount > preCompPoints) {
@@ -60,34 +59,37 @@ private static Set calculateFibonacciSphere(int pointCount, double rad
points.add(new Vector3d(r * Math.cos(theta), y * radius, r * Math.sin(theta)));
y -= yStep;
if (y <= yLimit) {
- return points;
+ return;
}
}
}
- return points;
}
// --- Generation methods ---
@Override
- public void generateOutline(Set points, double density) {
+ public void generateOutline(List points, double density) {
this.generateSurface(points, density);
}
@Override
- public void generateSurface(Set points, double density) {
+ public void generateSurface(List points, double density) {
int pointCount = 4 * (int) (Math.PI * radius * radius / (density * density));
- points.addAll(calculateFibonacciSphere(pointCount, radius, cutoffAngle));
+ if (points instanceof ArrayList> arrayList)
+ arrayList.ensureCapacity(points.size() + pointCount);
+ calculateFibonacciSphere(points, pointCount, radius, cutoffAngle);
}
@Override
- public void generateFilled(Set points, double density) {
+ public void generateFilled(List