diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 904712e..8e5ac19 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -1,9 +1,5 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. -# This workflow will build a Java project with Gradle and cache/restore any dependencies to improve the workflow execution time -# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle +# Builds both modules and runs their JUnit tests. +# The Skript tests (skriptTest) are not run here: the skript-test Gradle plugin is only published to mavenLocal for now. name: Java CI with Gradle @@ -12,6 +8,7 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + workflow_dispatch: permissions: contents: read @@ -23,21 +20,40 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Set up JDK 17 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '25' distribution: 'temurin' - cache: gradle + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + run: chmod +x gradlew + # 'build' runs the JUnit tests of shapes-lib and skript-particle before packaging the plugin + - name: Build and test with Gradle + run: ./gradlew build + # Shows the results on the job's summary page and annotates failing tests. annotate_only skips creating a + # separate check run, which would need 'checks: write' (and is unavailable to PRs from forks). + - name: Publish test results + uses: mikepenz/action-junit-report@v5 + if: always() with: - arguments: build + report_paths: '*/build/test-results/test/TEST-*.xml' + annotate_only: true + detailed_summary: true + include_passed: true + group_suite: true + - name: Upload test reports + uses: actions/upload-artifact@v6 + if: failure() + with: + name: test-reports + path: | + */build/reports/tests/test/ + */build/test-results/test/*.xml - name: Upload Nightly Build uses: actions/upload-artifact@v6 if: success() with: - name: skript-particle-nightly - path: build/libs/* + name: skript-particle-nightly + path: skript-particle/build/libs/*.jar diff --git a/.gitignore b/.gitignore index 965fe30..3cd03c3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,8 @@ /src/test/skriptparticle/ CLAUDE.md /.claude -/shapes-lib/build -/skript-particle/build +/shapes-lib/build/ +/skript-particle/build/ +/skript-particle/.gradle/ +/net/minecraft/network +/benchmark diff --git a/README.md b/README.md index 72a7bf0..ef95721 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # skript-particle Skript addon for creating complex particle effects with minimal hassle. - **Requires Skript 2.14+, Java 21, Paper 1.21.1+** + **Requires Skript 2.16+, Java 21, Paper 1.21.1+** Download at the [releases page.](https://github.com/sovdeeth/skript-particle/releases) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 41d9927..61285a6 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..325da1a 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.9.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1b6c787..adff685 100644 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,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/platforms/jvm/plugins-application/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 +82,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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,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 @@ -165,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,18 +198,27 @@ 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, 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" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# 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..c4bdd3a 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,11 +13,13 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@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 +27,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,13 +43,13 @@ 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. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,33 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%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..efbd40b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,12 @@ +pluginManagement { + repositories { + mavenLocal() + 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..5afd75b 100644 --- a/shapes-lib/build.gradle +++ b/shapes-lib/build.gradle @@ -10,8 +10,15 @@ 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 { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) } 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. + * + */ + 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}. + * + * + * @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}. + * + * + * @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..c1bac4e --- /dev/null +++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/PointContext.java @@ -0,0 +1,82 @@ +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 distance from the origin [0, 1], relative to the furthest point. + */ + public double normalizedSpherical() { + return bounds.normalizeSpherical(x, y, z); + } + + /** + * @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 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:

+ * + */ +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..fa51579 --- /dev/null +++ b/shapes-lib/src/main/java/com/sovdee/shapes/modifiers/ShapeBounds.java @@ -0,0 +1,211 @@ +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; + private final double maxDistance; + + // 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, double maxDistance) { + 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.maxDistance = maxDistance; + 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, 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; + double maxDistanceSquared = 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; + double distanceSquared = p.x * p.x + p.y * p.y + p.z * p.z; + if (distanceSquared > maxDistanceSquared) maxDistanceSquared = distanceSquared; + } + return new ShapeBounds(minX, minY, minZ, maxX, maxY, maxZ, maxRadius, Math.sqrt(maxDistanceSquared)); + } + + /** + * 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 largest distance of any point from the origin, in all three dimensions + */ + public double maxDistance() { + return maxDistance; + } + + /** + * Normalizes a point's distance from the origin to {@code [0, 1]}, where 1 is the furthest point. + * + * @return the normalized distance, or {@code 0} if every point is at the origin + */ + public double normalizeSpherical(double x, double y, double z) { + return maxDistance > 1e-6 ? Math.sqrt(x * x + y * y + z * z) / maxDistance : 0.0; + } + + /** + * @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:

+ * + */ +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..0be8ad5 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,123 +1,682 @@ 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.Collection; +import java.util.Collections; import java.util.Comparator; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.TreeSet; +import java.util.List; import java.util.UUID; /** - * Default implementation of {@link PointSampler} with hash-based caching. + * Default implementation of {@link PointSampler}. + *

+ * The last sampled points are cached as an immutable snapshot, which is shared with clones of this sampler + * until either side's configuration or shape changes. */ public class DefaultPointSampler implements PointSampler { + /** The point spacing used when no density has been set explicitly. */ + public static final double DEFAULT_DENSITY = 0.25; + + /** The point limit of a shape at its default density, until a limit is set. */ + public static final int DEFAULT_MAX_POINTS = 10_000; + /** + * The most points any sampling may produce, explicit density or not, and the point limit of a shape with an + * explicit density until a limit is set. A density that would exceed it is coarsened just enough to fit, + * so a single oversized shape can't exhaust the server's memory. + */ + public static final int HARD_MAX_POINTS = 100_000; + /** How many times a sampling may be retried at a coarser density to get under the cap. */ + private static final int MAX_CAP_ATTEMPTS = 48; + /** How many samplings {@link #setParticleCount(Shape, int)} may use to home in on its target. */ + private static final int MAX_COUNT_REFINEMENTS = 10; + private SamplingStyle style = SamplingStyle.OUTLINE; - private double density = 0.25; + private double density = DEFAULT_DENSITY; + private int maxPoints = DEFAULT_MAX_POINTS; + // until a limit is set, the limit depends on whether the density is explicit + private boolean maxPointsExplicit = false; + private boolean densityExplicit = false; private Comparator ordering; - private final UUID uuid; + private volatile UUID uuid; private DrawContext drawContext; + private List> modifiers = new ArrayList<>(); - // Cache - private Set cachedPoints = new LinkedHashSet<>(); - private CacheState lastState; - private boolean needsUpdate = false; + // Cache: an immutable snapshot, swapped atomically and shared with clones. + private volatile Cache cache; + /** + * 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. + * The sampler's {@link UUID} is generated when it is first requested. + */ public DefaultPointSampler() { - this.uuid = UUID.randomUUID(); - this.lastState = new CacheState(style, 0, 1.0, 0, density, 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) { - CacheState state = new CacheState(style, orientation.hashCode(), - shape.getScale(), shape.getOffset().hashCode(), density, shape.getVersion()); - if (shape.isDynamic() || needsUpdate || !state.equals(lastState) || cachedPoints.isEmpty()) { - Set points = (ordering != null) ? new TreeSet<>(ordering) : new LinkedHashSet<>(); + public synchronized List getPoints(Shape shape, Quaterniond orientation) { + // let the shape pick up any changes to its external inputs before checking the cache + shape.refresh(); + + int cap = getMaxPoints(); + CacheState state = new CacheState(style, new Quaterniond(orientation), shape.getScale(), shape.getOffset(), + density, densityExplicit, cap, shape.getVersion(), computeModifierHash()); + Cache cache = this.cache; + if (cache != null && cache.state.equals(state)) + return cache.points; + + // an explicit density is used exactly unless it exceeds the limit; otherwise the density is fitted to the limit + Sample sample = densityExplicit + ? sampleExplicit(shape, cap) + : sampleWithinCap(shape, cap); + List points = sample.points; + + applyGeometryModifiers(shape, points); + + // Sorted in local space, so the order turns with the shape. List#sort is stable, so points the ordering + // considers equal are all kept in their generated order. + if (ordering != null) { + points.sort(ordering); + } + + double scale = shape.getScale(); + Vector3d offset = shape.getOffset(); + for (Vector3d point : points) { + orientation.transform(point); + point.mul(scale); + point.add(offset); + } + + List result = Collections.unmodifiableList(points); + this.cache = new Cache(state, result, sample.density); + return result; + } + + /** + * Generates the untransformed points of a shape at the given density. + * + * @param limit the most points that may be generated before a {@link PointLimitExceededException} is thrown + */ + private static List generate(Shape shape, SamplingStyle style, double density, int limit) { + List points = new BoundedList(limit); + shape.beforeSampling(density); + switch (style) { + case OUTLINE -> shape.generateOutline(points, density); + case SURFACE -> shape.generateSurface(points, density); + case FILL -> shape.generateFilled(points, density); + } + shape.afterSampling(points); + return points; + } - shape.beforeSampling(density); - switch (style) { - case OUTLINE -> shape.generateOutline(points, density); - case SURFACE -> shape.generateSurface(points, density); - case FILL -> shape.generateFilled(points, density); + /** + * Samples a shape at its explicit density, which is used exactly unless it would produce more than + * {@code cap} points. Only then is it coarsened, just enough to fit. + */ + private Sample sampleExplicit(Shape shape, int cap) { + try { + return new Sample(generate(shape, style, density, cap), density); + } catch (PointLimitExceededException e) { + return sampleWithinCap(shape, cap); + } + } + + /** + * Samples a shape at the current density, coarsening the density as needed so that no more than + * {@code cap} points are produced. + */ + private Sample sampleWithinCap(Shape shape, int cap) { + double working = density; + // the shape's own estimate is only a starting point: it is never trusted to be finer than the base density. + double estimate = shape.computeDensity(style, cap); + if (Double.isFinite(estimate) && estimate > working) + working = estimate; + + // generating far more points than the cap is wasted work, so stop early and coarsen instead + int limit = (int) Math.min((long) cap * 2 + 64, Integer.MAX_VALUE); + double dimensions = switch (style) { + case OUTLINE -> 1; + case SURFACE -> 2; + case FILL -> 3; + }; + List points = null; + for (int attempt = 0; attempt < MAX_CAP_ATTEMPTS; attempt++) { + try { + points = generate(shape, style, working, limit); + } catch (PointLimitExceededException e) { + points = null; + working *= 2; + continue; } - shape.afterSampling(points); + if (points.size() <= cap) + return new Sample(points, working); + // slightly overshoot so shapes that respond as expected land under the cap in one step + working *= Math.pow((double) points.size() / cap, 1 / dimensions) * 1.02; + } + // The shape did not respond to density. Keep what fits rather than exceeding the cap. + if (points == null) + points = new ArrayList<>(); + else if (points.size() > cap) + points = new ArrayList<>(points.subList(0, cap)); + return new Sample(points, working); + } - for (Vector3d point : points) { - orientation.transform(point); - point.mul(shape.getScale()); - point.add(shape.getOffset()); + /** + * Applies the geometry modifiers in order. Each modifier sees the bounds of the points as the previous + * modifiers left them. + */ + @SuppressWarnings("unchecked") + private void applyGeometryModifiers(Shape shape, List points) { + PointContext ctx = null; + for (PointModifier modifier : modifiers) { + if (!(modifier instanceof PreRenderPointModifier)) + continue; + PointModifier mod = (PointModifier) modifier; + if (ctx == null) { + ctx = new PointContext(); + ctx.shape = shape; + ctx.totalPoints = points.size(); + } + ShapeBounds bounds = ShapeBounds.compute(points); + ctx.bounds = bounds; + 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; + mod.modify(ctx); + p.x = ctx.x; p.y = ctx.y; p.z = ctx.z; } - cachedPoints = points; - lastState = state; - needsUpdate = false; } - return cachedPoints; } + /** + * Returns the density the shape is actually sampled at. This differs from {@link #getDensity()} when the + * density had to be coarsened to stay within {@link #getMaxPoints()}. + */ + @Override + public synchronized double getEffectiveDensity(Shape shape) { + getPoints(shape); + Cache cache = this.cache; + return cache != null ? cache.density : density; + } + + /** + * Sets an explicit density so that the shape samples to approximately the given number of points. + * Starts from the shape's {@link Shape#computeDensity(SamplingStyle, int) estimate} and corrects it by + * measuring how the point count actually responds, so shapes without an exact formula still land close. + */ + @Override + public synchronized void setParticleCount(Shape shape, int count) { + int target = Math.clamp(count, 1, HARD_MAX_POINTS); + shape.refresh(); + + // Start no finer than the density currently in use: the first sample then costs no more than drawing the + // shape as it is, and every later step towards more points is bounded. + double current = getEffectiveDensity(shape); + double estimate = shape.computeDensity(style, target); + if (Double.isFinite(estimate) && estimate > current) + current = estimate; + + int limit = (int) Math.min((long) target * 8 + 1024, Integer.MAX_VALUE); + double dimensions = switch (style) { + case OUTLINE -> 1; + case SURFACE -> 2; + case FILL -> 3; + }; + long tolerance = Math.max(1, target / 50); + double bestDensity = current; + long bestError = Long.MAX_VALUE; + double previousDensity = Double.NaN; + int previousCount = -1; + + for (int i = 0; i < MAX_COUNT_REFINEMENTS; i++) { + int actual; + try { + actual = generate(shape, style, current, limit).size(); + } catch (PointLimitExceededException e) { + // far too many points: back off and try again + previousCount = -1; + current *= 2; + continue; + } + long error = Math.abs((long) actual - target); + if (error < bestError) { + bestError = error; + bestDensity = current; + } + if (error <= tolerance) + break; + + // measure how the count scales with density (count ~ density^-dimensions) + if (previousCount > 0 && actual > 0 && actual != previousCount && current != previousDensity) { + double measured = Math.log((double) previousCount / actual) / Math.log(current / previousDensity); + if (Double.isFinite(measured)) + dimensions = Math.clamp(measured, 1, 3); + } + previousDensity = current; + previousCount = actual; + + double next = current * Math.pow(Math.max(actual, 1) / (double) target, 1 / dimensions); + if (actual < target) { + // when adding points, never step further than is safe for a 3-dimensional response + double safest = current * Math.pow(Math.max(actual, 1) / (target * 4.0), 1 / 3.0); + next = Math.max(next, safest); + } + next = Math.max(next, Shape.EPSILON); + if (next == current) + break; + current = next; + } + setDensity(bestDensity); + } + + /** + * 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); + preparePass(shape, points, renderer.getContext()).renderBatch(points, renderer); + } + + /** + * Returns the bounds of the given points. When they are this sampler's cached points, the bounds are + * computed once and reused for as long as the cache is valid. + */ + @Override + public ShapeBounds boundsOf(List points) { + Cache cache = this.cache; + if (cache != null && cache.points == points) + return cache.bounds(); + return ShapeBounds.compute(points); + } + + @Override + public RenderPass preparePass(Shape shape, List points, Context context) { + // 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 against the full point set + ShapeBounds bounds = boundsOf(points); + context.bounds = bounds; + context.totalPoints = points.size(); + context.shape = shape; + for (PointModifier mod : renderMods) { + mod.prepare(bounds); + } + return new RenderPass<>(context, renderMods); + } + + /** + * 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; + cache = null; } + /** + * @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. + * This is the base spacing between points. When it has not been set explicitly via {@link #setDensity}, + * sampling may use a coarser density to stay within {@link #getMaxPoints()}; + * see {@link #getEffectiveDensity(Shape)}. + * + * @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. + * Sampling still never produces more than {@link #HARD_MAX_POINTS} points; a density that would is coarsened + * to fit (see {@link #getEffectiveDensity(Shape)}). + * + * @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.needsUpdate = true; + this.densityExplicit = true; + } + + /** + * Returns the point limit in effect: the limit that was set, or, until one is set, + * {@link #DEFAULT_MAX_POINTS} at the default density and {@link #HARD_MAX_POINTS} at an explicit density. + */ + @Override + public int getMaxPoints() { + if (maxPointsExplicit) + return maxPoints; + return densityExplicit ? HARD_MAX_POINTS : DEFAULT_MAX_POINTS; } + /** + * Sets the point limit, clamped to between {@code 1} and {@link #HARD_MAX_POINTS}. It applies whether or not + * the density is explicit, and the density itself is left unchanged. + * + * @param maxPoints the maximum particle count + */ @Override - public Comparator getOrdering() { return ordering; } + public void setMaxPoints(int maxPoints) { + this.maxPoints = Math.clamp(maxPoints, 1, HARD_MAX_POINTS); + this.maxPointsExplicit = true; + } + + @Override + public void resetMaxPoints() { + this.maxPoints = DEFAULT_MAX_POINTS; + this.maxPointsExplicit = false; + } + /** + * Returns to the default density, limited by {@link #getMaxPoints()}. + */ + @Override + public void resetDensity() { + this.density = DEFAULT_DENSITY; + this.densityExplicit = false; + } + + /** + * Returns whether density was set explicitly via {@link #setDensity}. + * When {@code false}, the density is coarsened as needed to stay within {@link #getMaxPoints()}. + * + * @return {@code true} if density was set explicitly + */ + @Override + 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; + this.cache = null; } + /** + * Returns the unique identifier for this sampler instance. + * The UUID is generated on first request and never changes afterwards. + * + * @return the immutable UUID of this sampler + */ @Override - public UUID getUUID() { return uuid; } + public UUID getUUID() { + UUID id = uuid; + if (id == null) { + synchronized (this) { + id = uuid; + if (id == null) + uuid = id = UUID.randomUUID(); + } + } + return id; + } + /** + * @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) cache = null; + } + + /** + * 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 DefaultPointSampler clone() { + public void removeModifier(PointModifier modifier) { + if (modifiers.remove(modifier)) { + if (modifier instanceof PreRenderPointModifier) cache = null; + } + } + + /** + * 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) cache = null; + } + } + + 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 gets its own when it is first requested. + * + * @return a new {@code DefaultPointSampler} with the same configuration + */ + @Override + public synchronized DefaultPointSampler clone() { try { DefaultPointSampler copy = (DefaultPointSampler) super.clone(); - // Don't share the cache - copy.cachedPoints = new LinkedHashSet<>(); - copy.needsUpdate = true; + // The copy is its own sampler, but keeps the cached points: they are immutable, and stay valid for + // as long as the copy's configuration and shape match the ones they were sampled with. + copy.uuid = null; + // 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); } } - private record CacheState(SamplingStyle style, int orientationHash, double scale, - int offsetHash, double density, long shapeVersion) {} + private record CacheState(SamplingStyle style, Quaterniond orientation, double scale, Vector3d offset, + double density, boolean densityExplicit, int maxPoints, long shapeVersion, + int modifierHash) {} + + /** + * @param density the density the points were actually sampled at + */ + private static final class Cache { + final CacheState state; + final List points; + final double density; + // computed on first use; the points never change, so neither do their bounds + private volatile ShapeBounds bounds; + + Cache(CacheState state, List points, double density) { + this.state = state; + this.points = points; + this.density = density; + } + + ShapeBounds bounds() { + ShapeBounds b = bounds; + if (b == null) + bounds = b = ShapeBounds.compute(points); + return b; + } + } + + private record Sample(List points, double density) {} + + /** + * A list that refuses to grow beyond a set size, so that a bad density fails fast instead of exhausting memory. + */ + private static final class BoundedList extends ArrayList { + + private final int limit; + + BoundedList(int limit) { + this.limit = limit; + } + + @Override + public boolean add(Vector3d vector) { + if (size() >= limit) + throw new PointLimitExceededException(limit); + return super.add(vector); + } + + @Override + public void add(int index, Vector3d vector) { + if (size() >= limit) + throw new PointLimitExceededException(limit); + super.add(index, vector); + } + + @Override + public boolean addAll(Collection vectors) { + if ((long) size() + vectors.size() > limit) + throw new PointLimitExceededException(limit); + return super.addAll(vectors); + } + + @Override + public boolean addAll(int index, Collection vectors) { + if ((long) size() + vectors.size() > limit) + throw new PointLimitExceededException(limit); + return super.addAll(index, vectors); + } + + @Override + public void ensureCapacity(int minCapacity) { + // shapes pre-size from rough estimates, which can be wildly off; never reserve beyond the limit + super.ensureCapacity(Math.min(minCapacity, limit)); + } + } } 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/PointLimitExceededException.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointLimitExceededException.java new file mode 100644 index 0000000..9dbe9fc --- /dev/null +++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointLimitExceededException.java @@ -0,0 +1,21 @@ +package com.sovdee.shapes.sampling; + +/** + * Thrown when sampling a shape would produce more points than allowed. + */ +public class PointLimitExceededException extends RuntimeException { + + private final int limit; + + public PointLimitExceededException(int limit) { + super("Sampling this shape would produce more than " + limit + " points."); + this.limit = limit; + } + + /** + * @return the number of points that was exceeded + */ + public int getLimit() { + return limit; + } +} diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointOrder.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointOrder.java new file mode 100644 index 0000000..a3ddee9 --- /dev/null +++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/PointOrder.java @@ -0,0 +1,61 @@ +package com.sovdee.shapes.sampling; + +import com.sovdee.shapes.modifiers.StandardInput; +import org.joml.Vector3d; + +import java.util.Comparator; + +/** + * Common point orderings for {@link PointSampler#setOrdering(Comparator)}. Points are compared in the shape's + * local space, so these follow the shape as it rotates. + */ +public final class PointOrder { + + private PointOrder() {} + + /** From lowest to highest {@code x + y + z}: one corner of the shape to the opposite one. */ + public static final Comparator DIAGONAL = Comparator.comparingDouble(v -> v.x + v.y + v.z); + + /** + * A shuffled order that is the same every time a shape is sampled, so animations don't flicker between + * different orders. Points at the same position stay in their generated order. + */ + public static final Comparator RANDOM = Comparator.comparingLong(PointOrder::scramble); + + /** + * Orders points from where the given input is lowest to where it is highest. Matches the definitions the + * modifiers use: x/y/z along their axis, radius as the distance from the y axis, spherical as the distance + * from the center, and angle counterclockwise around the y axis starting at +x. + * + * @throws IllegalArgumentException for {@link StandardInput#T}, which is the draw order itself + */ + public static Comparator along(StandardInput input) { + return switch (input) { + case X -> Comparator.comparingDouble(v -> v.x); + case Y -> Comparator.comparingDouble(v -> v.y); + case Z -> Comparator.comparingDouble(v -> v.z); + case RADIUS -> Comparator.comparingDouble(v -> v.x * v.x + v.z * v.z); + case SPHERICAL -> Comparator.comparingDouble(Vector3d::lengthSquared); + case ANGLE -> Comparator.comparingDouble(PointOrder::angle); + case T -> throw new IllegalArgumentException("points cannot be ordered by their own draw order"); + }; + } + + private static double angle(Vector3d v) { + // same as PointContext#angle, before normalising + double a = Math.atan2(v.z, v.x); + return a < 0 ? a + 2 * Math.PI : a; + } + + private static long scramble(Vector3d v) { + long h = Double.doubleToLongBits(v.x); + h = h * 0x9E3779B97F4A7C15L + Double.doubleToLongBits(v.y); + h = h * 0x9E3779B97F4A7C15L + Double.doubleToLongBits(v.z); + // splitmix64 finaliser + h ^= h >>> 30; + h *= 0xBF58476D1CE4E5B9L; + h ^= h >>> 27; + h *= 0x94D049BB133111EBL; + return h ^ (h >>> 31); + } +} 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..2416bdd 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,14 @@ package com.sovdee.shapes.sampling; +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; 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,36 +17,206 @@ */ 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 may coarsen it to stay within {@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); + /** + * Returns the most points a sampling may currently produce. A shape whose density would produce more is + * sampled at a coarser density, just fine enough to stay within this limit. + * + * @return the point limit in effect + */ + int getMaxPoints(); + + /** + * Sets the point limit. It applies whether or not the density is explicit, and does not change the density. + * + * @param maxPoints the maximum particle count + */ + void setMaxPoints(int maxPoints); + + /** + * Returns to the implementation's default point limit, which may depend on whether the density is explicit. + */ + void resetMaxPoints(); + + /** + * Returns whether density was set explicitly via {@link #setDensity}. + * When {@code false}, the density is coarsened as needed to stay within {@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. Points are sorted in the shape's local + * space (after geometry modifiers, before orientation, scale and offset), so the order rotates with the shape. + * See {@link PointOrder} for common orderings. + * + * @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); + /** + * Returns to the implementation's default density, limited by {@link #getMaxPoints()}. + */ + void resetDensity(); + + /** + * Returns the density the given shape is actually sampled at, which may be coarser than + * {@link #getDensity()} when the density is not explicit. + */ + default double getEffectiveDensity(Shape shape) { + return getDensity(); + } + + /** + * @return the number of points the given shape currently samples to + */ + default int getPointCount(Shape shape) { + return getPoints(shape).size(); + } + /** * Samples points from the given shape using the shape's own orientation. + * The returned list is unmodifiable and may be cached and shared: callers must not modify its vectors. */ - Set getPoints(Shape shape); + List getPoints(Shape shape); /** * Samples points from the given shape using the given orientation. + * The returned list is unmodifiable and may be cached and shared: callers must not modify its vectors. */ - Set getPoints(Shape shape, Quaterniond orientation); + List getPoints(Shape shape, Quaterniond orientation); /** * Computes and sets the density to achieve approximately the given particle count. */ default void setParticleCount(Shape shape, int count) { - setDensity(shape.computeDensity(getStyle(), count)); + double density = shape.computeDensity(getStyle(), Math.max(count, 1)); + // an estimate of zero or less would mean an unbounded number of points + if (Double.isFinite(density) && density > Shape.EPSILON) + setDensity(density); } + // ---- 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 the bounds of a point list, typically one returned by {@link #getPoints}. + * Implementations may cache the bounds of their own cached points; the default computes them. + * + * @param points the points to bound + * @return the bounds of the points + */ + default ShapeBounds boundsOf(List points) { + return ShapeBounds.compute(points); + } + + /** + * Collects and prepares this sampler's render modifiers against the given (already sampled) + * point set, returning a {@link RenderPass} that can render the points in one or more + * sequential batches with a continuous point index. Used by clients that spread rendering + * over multiple ticks (e.g. animated draws); {@link #render} is the single-batch shortcut. + * + * @param shape the shape being rendered + * @param points the full, ordered point set for this pass + * @param context the renderer's context; its bounds/totalPoints/shape fields are populated + */ + RenderPass preparePass(Shape shape, List points, Context context); + + /** + * 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/RenderPass.java b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/RenderPass.java new file mode 100644 index 0000000..b9de5f8 --- /dev/null +++ b/shapes-lib/src/main/java/com/sovdee/shapes/sampling/RenderPass.java @@ -0,0 +1,54 @@ +package com.sovdee.shapes.sampling; + +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import org.joml.Vector3d; + +import java.util.List; + +/** + * A prepared render pass over a shape's points. Created by + * {@link PointSampler#preparePass(com.sovdee.shapes.shapes.Shape, List, PointContext)} after + * render modifiers have been collected, validated, and prepared against the full point set. + *

+ * Points may then be rendered in one or more sequential batches via {@link #renderBatch}; + * the pass keeps a running point index so that {@link PointContext#index} (and anything + * derived from it, such as normalized draw order) stays continuous across batches. + * A pass is single-use and not thread-safe; batches must be rendered in order. + */ +public final class RenderPass { + + private final Context context; + private final List> renderModifiers; + private int nextIndex = 0; + + RenderPass(Context context, List> renderModifiers) { + this.context = context; + this.renderModifiers = renderModifiers; + } + + /** + * Runs the render loop over one batch of points: for each point the context is reset, + * populated, passed through every render modifier, and handed to the renderer. + * {@link ShapeRenderer#begin} and {@link ShapeRenderer#end} are called around the batch. + * + * @param batch the points to render, in draw order + * @param renderer the renderer to send points to; must be the renderer whose context + * this pass was prepared with + */ + public void renderBatch(List batch, ShapeRenderer renderer) { + renderer.begin(batch.size()); + for (Vector3d p : batch) { + context.reset(); + context.x = p.x; + context.y = p.y; + context.z = p.z; + context.index = nextIndex++; + for (PointModifier mod : renderModifiers) { + mod.modify(context); + } + renderer.renderPoint(context); + } + renderer.end(); + } +} 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..cc4af3a 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,7 @@ import org.joml.Quaterniond; import org.joml.Vector3d; -import java.util.LinkedHashSet; -import java.util.Set; +import java.util.List; /** * Base implementation of {@link Shape} providing spatial transform, versioning, @@ -17,11 +16,16 @@ public abstract class AbstractShape implements Shape { private final Quaterniond orientation; private double scale; - private Vector3d offset; + private final Vector3d offset; private boolean dynamic = false; 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,46 +35,80 @@ 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; + this.offset.set(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 +116,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 +133,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,34 +190,129 @@ public double computeDensity(SamplingStyle style, int targetPointCount) { // --- Geometry query --- + /** + * {@inheritDoc} + */ @Override - public abstract boolean contains(Vector3d point); + public boolean contains(Vector3d point) { + return contains(point, EPSILON); + } + + /** + * Returns {@code true} if the given point lies within the volume of this shape, grown by {@code tolerance}. + * For shapes with no volume of their own (curves and flat shapes), the tolerance is how thick they are + * treated as being. + * + * @param point the point to test, in the same space as the sampled points + * @param tolerance how far outside the exact shape still counts as contained, in the shape's local units + * @return {@code true} if the point is contained by this shape + */ + public boolean contains(Vector3d point, double tolerance) { + if (scale == 0) + return false; + refresh(); + // undo the transform the sampler applies: orientation, then scale, then offset + Vector3d local = new Vector3d(point).sub(offset).div(scale); + orientation.transformInverse(local); + return containsLocal(local, Math.max(tolerance, 0)); + } + + /** + * Tests a point in this shape's local space, before orientation, scale and offset are applied. + * Concrete subclasses implement the containment test appropriate to their geometry. + * + * @param point the point to test, in local coordinates. Implementations may modify it. + * @param tolerance a non-negative distance outside the exact shape that still counts as contained + * @return {@code true} if the point is contained by this shape + */ + protected abstract boolean containsLocal(Vector3d point, double tolerance); // --- 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); - double heightStep = height / Math.round(height / density); - for (double y = 0; y < height; y += heightStep) { - for (Vector3d v : base) { - points.add(new Vector3d(v.x, y, v.z)); + /** + * 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 of them in evenly spaced layers, up to and 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) { + fillVertically(points, startIndex, height, density, true); + } + + /** + * Duplicates an existing slice of {@code points} in evenly spaced layers along the y-axis. + * The existing slice is the base layer and is not duplicated. + * + * @param points the list of points to extend + * @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 + * @param includeTop whether to add a layer at exactly {@code height}. Pass {@code false} when the top + * is generated separately, to avoid duplicate points. + */ + protected static void fillVertically(List points, int startIndex, double height, double density, boolean includeTop) { + int end = points.size(); + int layers = segments(height, density); + int last = includeTop ? layers : layers - 1; + for (int layer = 1; layer <= last; layer++) { + double y = height * layer / layers; + for (int i = startIndex; i < end; i++) { + Vector3d v = points.get(i); + points.add(new Vector3d(v.x, v.y + y, v.z)); } } } + /** + * Returns how many segments of roughly {@code density} length fit into {@code length}. + * Always at least 1, so that dividing a length by the result is safe. + */ + protected static int segments(double length, double density) { + double count = Math.round(Math.abs(length) / density); + if (!(count >= 1)) // also covers NaN + return 1; + return (int) Math.min(count, Integer.MAX_VALUE - 1); + } + // --- 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 +320,11 @@ 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()); + // The copy has the same geometry, so give it the same version: the cloned sampler's cached points + // then stay valid until either shape actually changes. + if (shape instanceof AbstractShape abstractShape) + abstractShape.version = this.version; 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..1a612e5 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 @@ -1,50 +1,128 @@ package com.sovdee.shapes.shapes; +import com.sovdee.shapes.sampling.SamplingStyle; 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); } + /** + * The surface of an arc is drawn filled, so its density is estimated the same way. + */ + @Override + public double computeDensity(SamplingStyle style, int targetPointCount) { + return super.computeDensity(style == SamplingStyle.SURFACE ? SamplingStyle.FILL : style, targetPointCount); + } + + /** + * {@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 + * @param tolerance how far outside the exact shape still counts as contained + * @return {@code true} if the point lies inside this arc sector + */ @Override - public boolean contains(Vector3d point) { - if (!super.contains(point)) return false; + protected boolean containsLocal(Vector3d point, double tolerance) { + if (!super.containsLocal(point, tolerance)) return false; + if (cutoffAngle >= Math.PI * 2 - EPSILON) return true; + double distance = Math.sqrt(point.x * point.x + point.z * point.z); + // the axis itself belongs to every sector + if (distance <= tolerance) return true; double angle = Math.atan2(point.z, point.x); if (angle < 0) angle += 2 * Math.PI; - return angle <= cutoffAngle; + // allow the tolerance, as an angle at this distance, on either side of the sector + double slack = tolerance / distance; + return angle <= cutoffAngle + slack || angle >= 2 * Math.PI - slack; } + /** + * 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..b526907 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 @@ -1,11 +1,11 @@ package com.sovdee.shapes.shapes; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.util.VectorUtil; import org.joml.Vector3d; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.function.Supplier; /** @@ -17,50 +17,97 @@ 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 = copyOf(pts); setDynamic(true); } + private static List copyOf(List points) { + List copy = new ArrayList<>(points.size()); + for (Vector3d point : points) + copy.add(new Vector3d(point)); + return copy; + } + + /** + * Re-reads the control point supplier, if any, and invalidates the curve when a control point moved. + */ + @Override + public void refresh() { + if (controlPointsSupplier == null) + return; + List pts = controlPointsSupplier.get(); + if (pts.size() < 2 || pts.equals(controlPoints)) + return; + this.controlPoints = copyOf(pts); + invalidate(); + } + + /** + * 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) { - if (controlPointsSupplier != null) { - List pts = controlPointsSupplier.get(); - this.controlPoints = new ArrayList<>(); - for (Vector3d cp : pts) - this.controlPoints.add(new Vector3d(cp)); - } - int steps = (int) (estimateLength() / density); + public void generateOutline(List points, double density) { + int steps = segments(estimateLength(), density); int n = controlPoints.size(); Vector3d[] temp = new Vector3d[n]; for (int i = 0; i < n; i++) temp[i] = new Vector3d(); - for (int step = 0; step < steps; step++) { + // include both ends of the curve + for (int step = 0; step <= steps; step++) { double t = (double) step / steps; double nt = 1 - t; for (int i = 0; i < n; i++) @@ -82,21 +129,42 @@ 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; + // n segments produce n + 1 points + int segments = Math.max(targetPointCount - 1, 1); + return estimateLength() / segments; } + /** + * 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 + * @param tolerance how far outside the exact shape still counts as contained + * @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 - int samples = Math.max((int) (estimateLength() / 0.1), 10); + protected boolean containsLocal(Vector3d point, double tolerance) { + // Approximate: test against the curve traced as a polyline + int samples = Math.max(segments(estimateLength(), 0.05), 16); int n = controlPoints.size(); Vector3d[] temp = new Vector3d[n]; for (int i = 0; i < n; i++) temp[i] = new Vector3d(); - - for (int step = 0; step <= samples; step++) { + Vector3d previous = new Vector3d(controlPoints.get(0)); + double limit = tolerance * tolerance; + for (int step = 1; step <= samples; step++) { double t = (double) step / samples; double nt = 1 - t; for (int i = 0; i < n; i++) temp[i].set(controlPoints.get(i)); @@ -105,34 +173,57 @@ public boolean contains(Vector3d point) { temp[i].mul(nt).add(new Vector3d(temp[i + 1]).mul(t)); } } - if (point.distance(temp[0]) <= EPSILON) return true; + if (VectorUtil.distanceToSegmentSquared(point, previous, temp[0]) <= limit) return true; + previous.set(temp[0]); } 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; - if (controlPointsSupplier != null) { - clone = new BezierCurve(controlPointsSupplier); - } else { - clone = new BezierCurve(this); - } + // copy the current control points rather than re-reading the supplier, so the copy has identical geometry + BezierCurve clone = new BezierCurve(this); + clone.controlPointsSupplier = this.controlPointsSupplier; return this.copyTo(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..38864b3 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,57 @@ 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: + *

+ * With {@code height > 0} the modes produce: + * + *

+ * 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 static final double FULL_CIRCLE = 2 * Math.PI; + 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,96 +63,140 @@ public Circle(double radius, double height) { // --- Static calculation methods --- - public static Set calculateCircle(double radius, double density, double cutoffAngle) { - Set points = new LinkedHashSet<>(); - double stepSize = density / radius; - for (double theta = 0; theta < cutoffAngle; theta += stepSize) { + /** + * 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) { + boolean full = cutoffAngle >= FULL_CIRCLE - Shape.EPSILON; + double angle = full ? FULL_CIRCLE : cutoffAngle; + int steps = segments(angle * radius, density); + // a full circle closes on itself, so its last point would repeat the first; an arc includes both ends + int last = full ? steps - 1 : steps; + if (points instanceof ArrayList al) + al.ensureCapacity(points.size() + last + 1); + for (int i = 0; i <= last; i++) { + double theta = angle * i / steps; 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<>(); - for (double subRadius = density; subRadius < radius; subRadius += density) { - points.addAll(calculateCircle(subRadius, density, cutoffAngle)); + /** + * 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) { + points.add(new Vector3d(0, 0, 0)); + int rings = segments(radius, density); + for (int i = 1; i <= rings; i++) { + calculateCircle(points, radius * i / rings, density, cutoffAngle); } - points.addAll(calculateCircle(radius, density, cutoffAngle)); - return points; } - 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: + *

    + *
  1. A bottom disc at Y = 0.
  2. + *
  3. A top disc at Y = {@code height} (copied from the bottom disc).
  4. + *
  5. The curved wall, filled vertically via {@link #fillVertically}.
  6. + *
+ * + * @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(); + // Wall: copies of the outer ring between the two discs. The discs already contain the rings at the + // bottom and top, so the base ring is only generated to be copied, and is then removed. + calculateCircle(points, radius, density, cutoffAngle); + int ringEnd = points.size(); + fillVertically(points, discEnd, height, density, false); + points.subList(discEnd, ringEnd).clear(); + // 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; } // --- 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); } } @Override public double computeDensity(SamplingStyle style, int targetPointCount) { int count = Math.max(targetPointCount, 1); + double arcLength = cutoffAngle * radius; + double discArea = cutoffAngle * 0.5 * radius * radius; return switch (style) { case OUTLINE -> { - if (height == 0) yield cutoffAngle * radius / count; - double circumference = cutoffAngle * radius; - double wallArea = circumference * height; - yield Math.sqrt((circumference + wallArea) / count); + if (height == 0) yield arcLength / count; + // with a height, the outline is the wall + yield Math.sqrt(arcLength * height / count); } case SURFACE -> { - double discArea = cutoffAngle * 0.5 * radius * radius; - double wallArea = cutoffAngle * radius * height; - yield Math.sqrt((discArea + wallArea) / count); + if (height == 0) yield Math.sqrt(discArea / count); + yield Math.sqrt((2 * discArea + arcLength * height) / count); + } + case FILL -> { + // a flat circle has no volume: it is filled as a disc + if (height == 0) yield Math.sqrt(discArea / count); + yield Math.cbrt(discArea * height / count); } - case FILL -> Math.cbrt(cutoffAngle * 0.5 * radius * radius * height / count); }; } @Override - public boolean contains(Vector3d point) { - double distSq = point.x * point.x + point.z * point.z; - if (distSq > radius * radius) return false; - if (height > 0) return point.y >= 0 && point.y <= height; - return Math.abs(point.y) < EPSILON; + protected boolean containsLocal(Vector3d point, double tolerance) { + if (height > 0) { + if (point.y < -tolerance || point.y > height + tolerance) return false; + } else if (Math.abs(point.y) > tolerance) { + return false; + } + double maxDistance = radius + tolerance; + return point.x * point.x + point.z * point.z <= maxDistance * maxDistance; } @Override 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..8526ec4 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,21 +3,40 @@ 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 the cuboid is sampled (see {@link #refresh()}). + *
+ * For entity-following dynamic cuboids in the plugin layer, use the {@code DynamicCuboid} wrapper. */ public class Cuboid extends AbstractShape implements LWHShape { private double halfLength, halfWidth, halfHeight; - private double lengthStep, widthStep, heightStep; private Vector3d centerOffset = new Vector3d(0, 0, 0); private Supplier cornerASupplier; private Supplier cornerBSupplier; + // the corners last read from the suppliers, to detect when they move + private Vector3d lastCornerA; + private Vector3d lastCornerB; + /** + * 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,106 +44,207 @@ 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)) throw new IllegalArgumentException("Cuboid corners cannot be equal."); - this.halfLength = Math.abs(cornerB.x - cornerA.x) / 2; - this.halfWidth = Math.abs(cornerB.z - cornerA.z) / 2; - this.halfHeight = Math.abs(cornerB.y - cornerA.y) / 2; - centerOffset = new Vector3d(cornerB).add(cornerA).mul(0.5); + setCorners(cornerA, cornerB); } + /** + * 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 before every sampling (see {@link #refresh()}) 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; this.cornerBSupplier = cornerB; - Vector3d a = cornerA.get(); - Vector3d b = cornerB.get(); - this.halfLength = Math.max(Math.abs(b.x - a.x) / 2, Shape.EPSILON); - this.halfWidth = Math.max(Math.abs(b.z - a.z) / 2, Shape.EPSILON); - this.halfHeight = Math.max(Math.abs(b.y - a.y) / 2, Shape.EPSILON); + this.lastCornerA = new Vector3d(cornerA.get()); + this.lastCornerB = new Vector3d(cornerB.get()); + setCorners(lastCornerA, lastCornerB); setDynamic(true); } - private void calculateSteps(double density) { - widthStep = 2 * halfWidth / Math.round(2 * halfWidth / density); - lengthStep = 2 * halfLength / Math.round(2 * halfLength / density); - heightStep = 2 * halfHeight / Math.round(2 * halfHeight / density); + /** + * Sizes the cuboid to span the two corners, and centers it between them. + */ + private void setCorners(Vector3d cornerA, Vector3d cornerB) { + this.halfLength = Math.max(Math.abs(cornerB.x - cornerA.x) / 2, Shape.EPSILON); + this.halfWidth = Math.max(Math.abs(cornerB.z - cornerA.z) / 2, Shape.EPSILON); + this.halfHeight = Math.max(Math.abs(cornerB.y - cornerA.y) / 2, Shape.EPSILON); + this.centerOffset = new Vector3d(cornerB).add(cornerA).mul(0.5); } + /** + * Re-reads the corner suppliers, if any, and invalidates the cuboid when a corner moved. + */ @Override - public void beforeSampling(double density) { - if (cornerASupplier != null && cornerBSupplier != null) { - Vector3d a = cornerASupplier.get(); - Vector3d b = cornerBSupplier.get(); - this.halfLength = Math.max(Math.abs(b.x - a.x) / 2, Shape.EPSILON); - this.halfWidth = Math.max(Math.abs(b.z - a.z) / 2, Shape.EPSILON); - this.halfHeight = Math.max(Math.abs(b.y - a.y) / 2, Shape.EPSILON); - } - calculateSteps(density); - } - - @Override - public void afterSampling(Set points) { - points.forEach(vector -> vector.add(centerOffset)); + public void refresh() { + if (cornerASupplier == null || cornerBSupplier == null) + return; + Vector3d a = cornerASupplier.get(); + Vector3d b = cornerBSupplier.get(); + if (a.equals(lastCornerA) && b.equals(lastCornerB)) + return; + lastCornerA = new Vector3d(a); + lastCornerB = new Vector3d(b); + setCorners(a, b); + invalidate(); } + /** + * 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) { - for (double x = -halfLength; x <= halfLength; x += lengthStep) { + public void generateOutline(List points, double density) { + int start = points.size(); + int xSegments = segments(2 * halfLength, density); + int ySegments = segments(2 * halfHeight, density); + int zSegments = segments(2 * halfWidth, density); + // the four edges along x include the corners; the other edges only add the points between them + for (int i = 0; i <= xSegments; i++) { + double x = -halfLength + 2 * halfLength * i / xSegments; points.add(new Vector3d(x, -halfHeight, -halfWidth)); points.add(new Vector3d(x, -halfHeight, halfWidth)); points.add(new Vector3d(x, halfHeight, -halfWidth)); points.add(new Vector3d(x, halfHeight, halfWidth)); } - for (double y = -halfHeight + heightStep; y < halfHeight; y += heightStep) { + for (int i = 1; i < ySegments; i++) { + double y = -halfHeight + 2 * halfHeight * i / ySegments; points.add(new Vector3d(-halfLength, y, -halfWidth)); points.add(new Vector3d(-halfLength, y, halfWidth)); points.add(new Vector3d(halfLength, y, -halfWidth)); points.add(new Vector3d(halfLength, y, halfWidth)); } - for (double z = -halfWidth + widthStep; z < halfWidth; z += widthStep) { + for (int i = 1; i < zSegments; i++) { + double z = -halfWidth + 2 * halfWidth * i / zSegments; points.add(new Vector3d(-halfLength, -halfHeight, z)); points.add(new Vector3d(-halfLength, halfHeight, z)); points.add(new Vector3d(halfLength, -halfHeight, z)); points.add(new Vector3d(halfLength, halfHeight, z)); } + applyCenterOffset(points, start); } + /** + * 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) { - for (double x = -halfLength; x <= halfLength; x += lengthStep) { - for (double z = -halfWidth; z <= halfWidth; z += widthStep) { + public void generateSurface(List points, double density) { + int start = points.size(); + int xSegments = segments(2 * halfLength, density); + int ySegments = segments(2 * halfHeight, density); + int zSegments = segments(2 * halfWidth, density); + // top and bottom faces, including their edges + for (int i = 0; i <= xSegments; i++) { + double x = -halfLength + 2 * halfLength * i / xSegments; + for (int k = 0; k <= zSegments; k++) { + double z = -halfWidth + 2 * halfWidth * k / zSegments; points.add(new Vector3d(x, -halfHeight, z)); points.add(new Vector3d(x, halfHeight, z)); } } - for (double y = -halfHeight + heightStep; y < halfHeight; y += heightStep) { - for (double z = -halfWidth; z <= halfWidth; z += widthStep) { + // the remaining faces, without the edges already covered + for (int j = 1; j < ySegments; j++) { + double y = -halfHeight + 2 * halfHeight * j / ySegments; + for (int k = 0; k <= zSegments; k++) { + double z = -halfWidth + 2 * halfWidth * k / zSegments; points.add(new Vector3d(-halfLength, y, z)); points.add(new Vector3d(halfLength, y, z)); } - } - for (double x = -halfLength + lengthStep; x < halfLength; x += lengthStep) { - for (double y = -halfHeight + heightStep; y < halfHeight; y += heightStep) { + for (int i = 1; i < xSegments; i++) { + double x = -halfLength + 2 * halfLength * i / xSegments; points.add(new Vector3d(x, y, -halfWidth)); points.add(new Vector3d(x, y, halfWidth)); } } + applyCenterOffset(points, start); } + /** + * 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) { - for (double x = -halfLength; x <= halfLength; x += lengthStep) { - for (double y = -halfHeight; y <= halfHeight; y += heightStep) { - for (double z = -halfWidth; z <= halfWidth; z += widthStep) { + public void generateFilled(List points, double density) { + int start = points.size(); + int xSegments = segments(2 * halfLength, density); + int ySegments = segments(2 * halfHeight, density); + int zSegments = segments(2 * halfWidth, density); + for (int i = 0; i <= xSegments; i++) { + double x = -halfLength + 2 * halfLength * i / xSegments; + for (int j = 0; j <= ySegments; j++) { + double y = -halfHeight + 2 * halfHeight * j / ySegments; + for (int k = 0; k <= zSegments; k++) { + double z = -halfWidth + 2 * halfWidth * k / zSegments; points.add(new Vector3d(x, y, z)); } } } + applyCenterOffset(points, start); } + /** + * Moves the points generated since {@code start} from around the origin to around the cuboid's center. + */ + private void applyCenterOffset(List points, int start) { + if (centerOffset.x == 0 && centerOffset.y == 0 && centerOffset.z == 0) + return; + for (int i = start; i < points.size(); i++) { + points.get(i).add(centerOffset); + } + } + + /** + * 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,52 +255,115 @@ 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 + * @param tolerance how far outside the exact shape still counts as contained + * @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 && - Math.abs(point.y) <= halfHeight && - Math.abs(point.z) <= halfWidth; + protected boolean containsLocal(Vector3d point, double tolerance) { + point.sub(centerOffset); + return Math.abs(point.x) <= halfLength + tolerance && + Math.abs(point.y) <= halfHeight + tolerance && + Math.abs(point.z) <= halfWidth + tolerance; } + /** + * 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; - if (cornerASupplier != null && cornerBSupplier != null) { - cuboid = new Cuboid(cornerASupplier, cornerBSupplier); - } else { - cuboid = new Cuboid(getLength(), getWidth(), getHeight()); - } + // copy the current state rather than re-reading the suppliers, so the copy has identical geometry + Cuboid cuboid = new Cuboid(getLength(), getWidth(), getHeight()); cuboid.centerOffset = new Vector3d(this.centerOffset); + cuboid.cornerASupplier = this.cornerASupplier; + cuboid.cornerBSupplier = this.cornerBSupplier; + cuboid.lastCornerA = this.lastCornerA; + cuboid.lastCornerB = this.lastCornerB; return this.copyTo(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..62fdaa4 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,21 +4,51 @@ 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 static final double FULL_CIRCLE = 2 * Math.PI; + private double xRadius; private double zRadius; 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); @@ -29,22 +59,41 @@ public Ellipse(double xRadius, double zRadius, double height) { // --- Static calculation methods --- - private static double ellipseCircumference(double r1, double r2) { + static double ellipseCircumference(double r1, double r2) { double a = Math.max(r1, r2); double b = Math.min(r1, r2); + if (!(a + b > 0)) + return 0; double h = Math.pow(a - b, 2) / Math.pow(a + b, 2); 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); + boolean full = cutoffAngle >= FULL_CIRCLE - Shape.EPSILON; - int steps = (int) Math.round(circumference / density); + long steps = Math.round(circumference / density); + if (points instanceof ArrayList al) + al.ensureCapacity(points.size() + (int) Math.min(steps * Math.min(cutoffAngle, FULL_CIRCLE) / FULL_CIRCLE + 1, Integer.MAX_VALUE - 8)); double theta = 0; double angleStep = 0; - for (int i = 0; i < steps; i++) { - if (theta > cutoffAngle) { + for (long i = 0; i < steps; i++) { + // a full ellipse stops before wrapping onto its first point; an arc stops once past its end + if (full ? theta >= FULL_CIRCLE - angleStep / 2 : theta > cutoffAngle) { break; } points.add(new Vector3d(r1 * Math.cos(theta), 0, r2 * Math.sin(theta))); @@ -53,116 +102,239 @@ 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<>(); - 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)); + /** + * 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) { + points.add(new Vector3d(0, 0, 0)); + int rings = segments(Math.max(r1, r2), density); + for (int i = 1; i <= rings; i++) { + double r = (double) i / rings; + 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(); + // Wall: copies of the outer ring between the two discs. The discs already contain the rings at the + // bottom and top, so the base ring is only generated to be copied, and is then removed. + calculateEllipse(points, r1, r2, density, cutoffAngle); + int ringEnd = points.size(); + fillVertically(points, discEnd, height, density, false); + points.subList(discEnd, ringEnd).clear(); + // 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; } // --- 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); + double fraction = Math.min(cutoffAngle, FULL_CIRCLE) / FULL_CIRCLE; + double arcLength = ellipseCircumference(xRadius, zRadius) * fraction; + double discArea = Math.PI * xRadius * zRadius * fraction; return switch (style) { case OUTLINE -> { - double h = (xRadius - zRadius) * (xRadius - zRadius) / ((xRadius + zRadius) + (xRadius + zRadius)); - double circumference = Math.PI * (xRadius + zRadius) * (1 + (3 * h / (10 + Math.sqrt(4 - 3 * h)))); - yield circumference / count; + if (height == 0) yield arcLength / count; + // with a height, the outline is the wall + yield Math.sqrt(arcLength * height / count); + } + case SURFACE -> { + if (height == 0) yield Math.sqrt(discArea / count); + yield Math.sqrt((2 * discArea + arcLength * height) / count); + } + case FILL -> { + // a flat ellipse has no volume: it is filled as a disc + if (height == 0) yield Math.sqrt(discArea / count); + yield Math.cbrt(discArea * height / count); } - case SURFACE, FILL -> Math.sqrt((Math.PI * xRadius * zRadius) / count); }; } + /** + * 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 + * @param tolerance how far outside the exact shape still counts as contained + * @return {@code true} if the point is contained within the ellipse (or cylinder) + */ @Override - public boolean contains(Vector3d point) { - double nx = point.x / xRadius; - double nz = point.z / zRadius; - if (nx * nx + nz * nz > 1) return false; - if (height > 0) return point.y >= 0 && point.y <= height; - return Math.abs(point.y) < EPSILON; + protected boolean containsLocal(Vector3d point, double tolerance) { + if (height > 0) { + if (point.y < -tolerance || point.y > height + tolerance) return false; + } else if (Math.abs(point.y) > tolerance) { + return false; + } + double nx = point.x / (xRadius + tolerance); + double nz = point.z / (zRadius + tolerance); + return nx * nx + nz * nz <= 1; } + /** + * 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..6b7433d 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,67 +43,104 @@ 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)); + // the equator is added once, after the loop + if (Math.abs(y) <= EPSILON) + continue; + double theta = Math.asin(Math.clamp(y / (yRadius * radius), -1.0, 1.0)); + // 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 public double computeDensity(SamplingStyle style, int targetPointCount) { int count = Math.max(targetPointCount, 1); return switch (style) { - case OUTLINE -> { - double h = (xRadius - yRadius) * (xRadius - yRadius) / ((xRadius + yRadius) + (xRadius + yRadius)); - double circumferenceXY = Math.PI * (xRadius + yRadius) * (1 + (3 * h / (10 + Math.sqrt(4 - 3 * h)))); - h = (xRadius - zRadius) * (xRadius - zRadius) / ((xRadius + zRadius) + (xRadius + zRadius)); - double circumferenceXZ = Math.PI * (xRadius + zRadius) * (1 + (3 * h / (10 + Math.sqrt(4 - 3 * h)))); - h = (yRadius - zRadius) * (yRadius - zRadius) / ((yRadius + zRadius) + (yRadius + zRadius)); - double circumferenceYZ = Math.PI * (yRadius + zRadius) * (1 + (3 * h / (10 + Math.sqrt(4 - 3 * h)))); - yield (circumferenceXY + circumferenceXZ + circumferenceYZ) / count; - } + case OUTLINE -> (Ellipse.ellipseCircumference(xRadius, yRadius) + + Ellipse.ellipseCircumference(xRadius, zRadius) + + Ellipse.ellipseCircumference(yRadius, zRadius)) / count; case SURFACE -> { double surfaceArea = 4 * Math.PI * Math.pow((Math.pow(xRadius * yRadius, 1.6) + Math.pow(xRadius * zRadius, 1.6) + Math.pow(zRadius * yRadius, 1.6)) / 3, 1 / 1.6); yield Math.sqrt(surfaceArea / count); @@ -97,10 +153,10 @@ public double computeDensity(SamplingStyle style, int targetPointCount) { } @Override - public boolean contains(Vector3d point) { - double nx = point.x / xRadius; - double ny = point.y / yRadius; - double nz = point.z / zRadius; + protected boolean containsLocal(Vector3d point, double tolerance) { + double nx = point.x / (xRadius + tolerance); + double ny = point.y / (yRadius + tolerance); + double nz = point.z / (zRadius + tolerance); return nx * nx + ny * ny + nz * nz <= 1; } 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..167567e 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 @@ -1,8 +1,9 @@ package com.sovdee.shapes.shapes; +import com.sovdee.shapes.sampling.SamplingStyle; import org.joml.Vector3d; -import java.util.Set; +import java.util.List; public class EllipticalArc extends Ellipse implements CutoffShape { @@ -16,10 +17,18 @@ 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); } + /** + * The surface of an arc is drawn filled, so its density is estimated the same way. + */ + @Override + public double computeDensity(SamplingStyle style, int targetPointCount) { + return super.computeDensity(style == SamplingStyle.SURFACE ? SamplingStyle.FILL : style, targetPointCount); + } + @Override public double getCutoffAngle() { return cutoffAngle; @@ -32,11 +41,18 @@ public void setCutoffAngle(double cutoffAngle) { } @Override - public boolean contains(Vector3d point) { - if (!super.contains(point)) return false; - double angle = Math.atan2(point.z, point.x); + protected boolean containsLocal(Vector3d point, double tolerance) { + if (!super.containsLocal(point, tolerance)) return false; + if (cutoffAngle >= Math.PI * 2 - EPSILON) return true; + double distance = Math.sqrt(point.x * point.x + point.z * point.z); + // the axis itself belongs to every sector + if (distance <= tolerance) return true; + // the cutoff of an elliptical arc is a parametric angle, so compare in the unit circle's space + double angle = Math.atan2(point.z / (getWidth() / 2), point.x / (getLength() / 2)); if (angle < 0) angle += 2 * Math.PI; - return angle <= cutoffAngle; + // allow the tolerance, as an angle at this distance, on either side of the sector + double slack = tolerance / distance; + return angle <= cutoffAngle + slack || angle >= 2 * Math.PI - slack; } @Override 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..93e2e84 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 @@ -1,10 +1,11 @@ package com.sovdee.shapes.shapes; import com.sovdee.shapes.sampling.SamplingStyle; +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; public class Heart extends AbstractShape implements LWHShape { @@ -19,48 +20,72 @@ 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<>(); - double angleStep = 4 / 3.0 * density / (width + length); - 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)); + private static void calculateHeart(List points, double length, double width, double eccentricity, double density) { + int steps = Math.max(segments(Math.PI * 2 * (width + length) * 3 / 4, density), 3); + if (points instanceof ArrayList al) + al.ensureCapacity(points.size() + steps); + for (int i = 0; i < steps; i++) { + points.add(heartPoint(length, width, eccentricity, Math.PI * 2 * i / steps)); } - return points; + } + + /** + * @return the point on the heart curve at the given parametric angle + */ + private static Vector3d heartPoint(double length, double width, double eccentricity, double theta) { + 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)); + return new Vector3d(x, 0, y); + } + + /** + * Traces the outline of this heart as a closed polygon. + * + * @param resolution the number of vertices + */ + List outlinePolygon(int resolution) { + List polygon = new ArrayList<>(resolution); + for (int i = 0; i < resolution; i++) { + polygon.add(heartPoint(length / 2, width / 2, eccentricity, Math.PI * 2 * i / resolution)); + } + return polygon; } @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); } } @Override public double computeDensity(SamplingStyle style, int targetPointCount) { - return 0.25; // No good formula available + int count = Math.max(targetPointCount, 1); + return switch (style) { + // the outline is stepped by angle, at this many points per unit of density + case OUTLINE -> Math.PI * 2 * (width / 2 + length / 2) * 3 / 4 / count; + case SURFACE, FILL -> { + // The surface is drawn as concentric outlines 1.5 densities apart, shrinking to nothing, + // so on average each has half the points of the outermost one. + double outline = Math.PI * 2 * (width / 2 + length / 2) * 3 / 4; + double rings = Math.min(width, length) / 1.5; + yield Math.sqrt(outline * rings / 2 / count); + } + }; } @Override - public boolean contains(Vector3d point) { - // Approximate: check if point is within parametric heart boundary - if (Math.abs(point.y) > EPSILON) return false; - double x = point.x; - double z = point.z; - double hw = width / 2; - double hl = length / 2; - if (hw < EPSILON || hl < EPSILON) return false; - // Normalize and check parametric distance - double nx = x / hw; - double nz = z / hl; - // Simple bounding check - return nx * nx + nz * nz <= 4; + protected boolean containsLocal(Vector3d point, double tolerance) { + if (Math.abs(point.y) > tolerance) return false; + // Approximate: test against the outline traced as a polygon + List outline = outlinePolygon(256); + return VectorUtil.isInPolygonXZ(outline, point.x, point.z) + || VectorUtil.isNearPolygonXZ(outline, point.x, point.z, tolerance); } @Override 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..9ed9256 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,52 +30,62 @@ 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; - for (double t = 0; t < loops; t += stepSize) { + // the helix is parameterised by its angle: one radian covers this much arc length + double totalAngle = Math.abs(height / slope); + double lengthPerRadian = Math.sqrt(slope * slope + radius * radius); + int steps = segments(totalAngle * lengthPerRadian, density); + if (points instanceof ArrayList al) + al.ensureCapacity(points.size() + steps + 1); + for (int i = 0; i <= steps; i++) { + double t = totalAngle * i / steps; 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) { - for (double r = radius; r > 0; r -= density) { - points.addAll(calculateHelix(r, height, slope, direction, density)); + public void generateSurface(List points, double density) { + int rings = segments(radius, density); + for (int i = rings; i >= 1; i--) { + calculateHelix(points, radius * i / rings, height, slope, direction, density); } } @Override public double computeDensity(SamplingStyle style, int targetPointCount) { int count = Math.max(targetPointCount, 1); + double totalAngle = height / slope; return switch (style) { - case OUTLINE -> Math.sqrt(slope * slope + radius * radius) * (height / slope) / count; - case FILL, SURFACE -> Math.sqrt(slope * slope + radius * radius * (height / slope) / count); + case OUTLINE -> Math.sqrt(slope * slope + radius * radius) * totalAngle / count; + case FILL, SURFACE -> { + // area of the helicoid swept between the axis and the helix + double area = totalAngle * 0.5 * (radius * Math.sqrt(slope * slope + radius * radius) + + slope * slope * Math.log((radius + Math.sqrt(slope * slope + radius * radius)) / slope)); + yield Math.sqrt(area / count); + } }; } @Override - public boolean contains(Vector3d point) { - // Approximate: check distance to nearest helix point - if (point.y < 0 || point.y > height) return false; - double t = point.y / slope; - double helixX = radius * Math.cos(direction * t); - double helixZ = radius * Math.sin(direction * t); - double dist = Math.sqrt((point.x - helixX) * (point.x - helixX) + (point.z - helixZ) * (point.z - helixZ)); - return dist <= EPSILON; + protected boolean containsLocal(Vector3d point, double tolerance) { + // Approximate: compare against the point of the helix at the same height + if (point.y < -tolerance || point.y > height + tolerance) return false; + double y = Math.clamp(point.y, 0.0, height); + double t = y / slope; + double dx = point.x - radius * Math.cos(direction * t); + double dz = point.z - radius * Math.sin(direction * t); + double dy = point.y - y; + return dx * dx + dy * dy + dz * dz <= tolerance * tolerance; } public double getSlope() { return slope; } 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..b041b30 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 @@ -1,13 +1,13 @@ package com.sovdee.shapes.shapes; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.util.VectorUtil; import org.joml.Vector3d; 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 +46,25 @@ 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(); + // one edge per vertex, each without its end point, so every vertex appears exactly once + int count = vertices.size(); + for (int i = 0; i < count; i++) { + Line.calculateLine(points, vertices.get(i), vertices.get((i + 1) % count), density, false); + } 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); + // vertical edges, without the vertices that the two outlines already contain + int layers = segments(height, density); for (Vector3d v : vertices) { - points.addAll(Line.calculateLine(v, new Vector3d(v.x, height, v.z), density)); + for (int layer = 1; layer < layers; layer++) { + points.add(new Vector3d(v.x, height * layer / layers, v.z)); + } } } } @@ -65,31 +73,24 @@ public void generateOutline(Set points, double density) { public double computeDensity(SamplingStyle style, int targetPointCount) { int count = Math.max(targetPointCount, 1); double perimeter = 0; - for (int i = 0; i < vertices.size() - 1; i++) { - perimeter += vertices.get(i).distance(vertices.get(i + 1)); + for (int i = 0; i < vertices.size(); i++) { + perimeter += vertices.get(i).distance(vertices.get((i + 1) % vertices.size())); } - perimeter += vertices.get(0).distance(vertices.get(vertices.size() - 1)); - perimeter *= 2; - perimeter += vertices.size() * height; - return perimeter / count; + if (height == 0) + return perimeter / count; + // both outlines plus one vertical edge per vertex + return (2 * perimeter + vertices.size() * height) / count; } @Override - public boolean contains(Vector3d point) { - if (height > 0 && (point.y < 0 || point.y > height)) return false; - if (height == 0 && Math.abs(point.y) > EPSILON) return false; - // Ray casting algorithm on XZ plane - int n = vertices.size(); - boolean inside = false; - for (int i = 0, j = n - 1; i < n; j = i++) { - double xi = vertices.get(i).x, zi = vertices.get(i).z; - double xj = vertices.get(j).x, zj = vertices.get(j).z; - if ((zi > point.z) != (zj > point.z) && - point.x < (xj - xi) * (point.z - zi) / (zj - zi) + xi) { - inside = !inside; - } + protected boolean containsLocal(Vector3d point, double tolerance) { + if (height > 0) { + if (point.y < -tolerance || point.y > height + tolerance) return false; + } else if (Math.abs(point.y) > tolerance) { + return false; } - return inside; + return VectorUtil.isInPolygonXZ(vertices, point.x, point.z) + || VectorUtil.isNearPolygonXZ(vertices, point.x, point.z, tolerance); } @Override 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..bf39fb7 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 @@ -1,172 +1,361 @@ package com.sovdee.shapes.shapes; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.util.VectorUtil; 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 { + // The current endpoints. For a dynamic line these are the values last read from the suppliers. + private final Vector3d start = new Vector3d(); + private final Vector3d end = new Vector3d(); 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)) throw new IllegalArgumentException("Start and end locations cannot be the same."); - final Vector3d s = new Vector3d(start); - final Vector3d e = new Vector3d(end); - this.startSupplier = () -> new Vector3d(s); - this.endSupplier = () -> new Vector3d(e); + this.start.set(start); + this.end.set(end); } + /** + * 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; this.endSupplier = end; + this.start.set(start.get()); + this.end.set(end.get()); setDynamic(true); } /** - * Calculates points along a line from start to end with the given density. + * Copies the endpoints and suppliers of another line without re-reading the suppliers, + * so the copy describes exactly the same geometry as the original. + */ + private Line(Line line) { + super(); + this.start.set(line.start); + this.end.set(line.end); + this.startSupplier = line.startSupplier; + this.endSupplier = line.endSupplier; + } + + /** + * 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<>(); - Vector3d direction = new Vector3d(end).sub(start); - double length = direction.length(); - double step = length / Math.round(length / density); - direction.normalize().mul(step); + public static void calculateLine(List points, Vector3d start, Vector3d end, double density) { + calculateLine(points, start, end, density, true); + } - Vector3d current = new Vector3d(start); - int count = (int) (length / step); - for (int i = 0; i <= count; i++) { - points.add(new Vector3d(current)); - current.add(direction); + /** + * Appends evenly spaced points along the segment from {@code start} to {@code end}, roughly + * {@code density} apart. The start point is always included. + * + * @param points the list to append the generated points to + * @param start the start of the segment + * @param end the end of the segment + * @param density the desired spacing between consecutive points + * @param includeEnd whether to include the end point. Pass {@code false} when chaining segments, + * so shared corners are not generated twice. + */ + public static void calculateLine(List points, Vector3d start, Vector3d end, double density, boolean includeEnd) { + double length = start.distance(end); + if (!(length > 0)) { + // a degenerate segment is a single point + if (includeEnd) + points.add(new Vector3d(start)); + return; + } + int segments = segments(length, density); + int last = includeEnd ? segments : segments - 1; + if (points instanceof ArrayList al) + al.ensureCapacity(points.size() + last + 1); + for (int i = 0; i <= last; i++) { + points.add(new Vector3d(start).lerp(end, (double) i / segments)); } - 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) { + int last = controlPoints.size() - 2; + for (int i = 0; i <= last; i++) { + // only the final segment contributes its end point, so each junction appears once + calculateLine(result, controlPoints.get(i), controlPoints.get(i + 1), density, i == last); } - return connectedPoints; } + /** + * Re-reads the endpoint suppliers, if any, and invalidates the line when an endpoint moved. + */ + @Override + public void refresh() { + boolean changed = false; + if (startSupplier != null) { + Vector3d value = startSupplier.get(); + if (!start.equals(value)) { + start.set(value); + changed = true; + } + } + if (endSupplier != null) { + Vector3d value = endSupplier.get(); + if (!end.equals(value)) { + end.set(value); + changed = true; + } + } + if (changed) + invalidate(); + } + + /** + * 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, start, end, 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(); + refresh(); + return new Vector3d(start); } + /** + * 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); + this.start.set(start); + this.startSupplier = null; + setDynamic(endSupplier != null); 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(); + refresh(); + return new Vector3d(end); } + /** + * 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); + this.end.set(end); + this.endSupplier = null; + setDynamic(startSupplier != null); 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; + setDynamic(startSupplier != null || endSupplier != null); } + /** + * 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; + setDynamic(startSupplier != null || endSupplier != null); } + /** + * 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; + // n segments produce n + 1 points + int segments = Math.max(targetPointCount - 1, 1); + return getLength() / segments; } + /** + * 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 + * @param tolerance how far outside the exact shape still counts as contained + * @return {@code true} if the point is on the segment + */ @Override - public boolean contains(Vector3d point) { - Vector3d start = getStart(); - Vector3d end = getEnd(); - Vector3d line = new Vector3d(end).sub(start); - double len = line.length(); - if (len < EPSILON) return point.distance(start) < EPSILON; - Vector3d toPoint = new Vector3d(point).sub(start); - double t = toPoint.dot(line) / (len * len); - if (t < 0 || t > 1) return false; - Vector3d closest = new Vector3d(start).add(new Vector3d(line).mul(t)); - return point.distance(closest) <= EPSILON; + protected boolean containsLocal(Vector3d point, double tolerance) { + return VectorUtil.distanceToSegmentSquared(point, start, end) <= tolerance * tolerance; } + /** + * 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(); + refresh(); + return start.distance(end); } + /** + * 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); Vector3d start = getStart(); - Vector3d end = getEnd(); - Vector3d direction = new Vector3d(end).sub(start).normalize(); - Vector3d newEnd = new Vector3d(start).add(direction.mul(length)); - setEnd(newEnd); + Vector3d direction = getEnd().sub(start); + if (direction.lengthSquared() == 0) + return; + setEnd(start.add(direction.normalize().mul(length))); } + /** + * 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; - if (isDynamic()) { - clone = new Line(this.startSupplier, this.endSupplier); - } else { - clone = new Line(getStart(), getEnd()); - } - return this.copyTo(clone); + return this.copyTo(new Line(this)); } + /** + * 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..d26e8c5 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,24 +3,43 @@ 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 { private Plane plane; private double halfLength; private double halfWidth; - private double lengthStep = 1.0; - private double widthStep = 1.0; private Vector3d centerOffset = new Vector3d(0, 0, 0); private Supplier cornerASupplier; private Supplier cornerBSupplier; + // the corners last read from the suppliers, to detect when they move + private Vector3d lastCornerA; + private Vector3d lastCornerB; + /** + * 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,11 +47,48 @@ 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)) throw new IllegalArgumentException("Corners cannot be the same."); this.plane = plane; + setCorners(cornerA, cornerB); + } + + /** + * 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; + this.cornerASupplier = cornerA; + this.cornerBSupplier = cornerB; + this.lastCornerA = new Vector3d(cornerA.get()); + this.lastCornerB = new Vector3d(cornerB.get()); + setCorners(lastCornerA, lastCornerB); + setDynamic(true); + } + + /** + * Sizes the rectangle to span the two corners, and centers it between them within its plane. + */ + private void setCorners(Vector3d cornerA, Vector3d cornerB) { setLengthWidth(cornerA, cornerB); centerOffset = new Vector3d(cornerB).add(cornerA).mul(0.5); switch (plane) { @@ -42,15 +98,21 @@ public Rectangle(Vector3d cornerA, Vector3d cornerB, Plane plane) { } } - public Rectangle(Supplier cornerA, Supplier cornerB, Plane plane) { - super(); - this.plane = plane; - this.cornerASupplier = cornerA; - this.cornerBSupplier = cornerB; - Vector3d a = cornerA.get(); - Vector3d b = cornerB.get(); - setLengthWidth(a, b); - setDynamic(true); + /** + * Re-reads the corner suppliers, if any, and invalidates the rectangle when a corner moved. + */ + @Override + public void refresh() { + if (cornerASupplier == null || cornerBSupplier == null) + return; + Vector3d a = cornerASupplier.get(); + Vector3d b = cornerBSupplier.get(); + if (a.equals(lastCornerA) && b.equals(lastCornerB)) + return; + lastCornerA = new Vector3d(a); + lastCornerB = new Vector3d(b); + setCorners(a, b); + invalidate(); } private void setLengthWidth(Vector3d cornerA, Vector3d cornerB) { @@ -62,8 +124,8 @@ private void setLengthWidth(Vector3d cornerA, Vector3d cornerB) { case XZ, YZ -> Math.abs(cornerA.z - cornerB.z); case XY -> Math.abs(cornerA.y - cornerB.y); }; - this.halfWidth = Math.abs(width) / 2; - this.halfLength = Math.abs(length) / 2; + this.halfWidth = Math.max(Math.abs(width) / 2, Shape.EPSILON); + this.halfLength = Math.max(Math.abs(length) / 2, Shape.EPSILON); } private Vector3d vectorFromLengthWidth(double length, double width) { @@ -74,47 +136,73 @@ private Vector3d vectorFromLengthWidth(double length, double width) { }; } - private void calculateSteps(double density) { - lengthStep = 2 * halfWidth / Math.round(2 * halfWidth / density); - widthStep = 2 * halfLength / Math.round(2 * halfLength / density); - } - - @Override - public void beforeSampling(double density) { - if (cornerASupplier != null && cornerBSupplier != null) { - Vector3d a = cornerASupplier.get(); - Vector3d b = cornerBSupplier.get(); - setLengthWidth(a, b); - } - calculateSteps(density); - } - + /** + * Generates outline (perimeter) points for the rectangle. Points are placed along all four + * edges, roughly {@code density} apart. + * + * @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 afterSampling(Set points) { - points.forEach(vector -> vector.add(centerOffset)); - } - - @Override - public void generateOutline(Set points, double density) { - for (double l = -halfLength + widthStep; l < halfLength; l += widthStep) { + public void generateOutline(List points, double density) { + int start = points.size(); + int lengthSegments = segments(2 * halfLength, density); + int widthSegments = segments(2 * halfWidth, density); + // the two sides along the width include the corners; the other two only add the points between them + for (int i = 1; i < lengthSegments; i++) { + double l = -halfLength + 2 * halfLength * i / lengthSegments; points.add(vectorFromLengthWidth(l, -halfWidth)); points.add(vectorFromLengthWidth(l, halfWidth)); } - for (double w = -halfWidth; w <= halfWidth; w += lengthStep) { + for (int i = 0; i <= widthSegments; i++) { + double w = -halfWidth + 2 * halfWidth * i / widthSegments; points.add(vectorFromLengthWidth(-halfLength, w)); points.add(vectorFromLengthWidth(halfLength, w)); } + applyCenterOffset(points, start); } + /** + * 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) { - for (double w = -halfWidth; w <= halfWidth; w += lengthStep) { - for (double l = -halfLength; l <= halfLength; l += widthStep) { + public void generateSurface(List points, double density) { + int start = points.size(); + int lengthSegments = segments(2 * halfLength, density); + int widthSegments = segments(2 * halfWidth, density); + for (int i = 0; i <= widthSegments; i++) { + double w = -halfWidth + 2 * halfWidth * i / widthSegments; + for (int j = 0; j <= lengthSegments; j++) { + double l = -halfLength + 2 * halfLength * j / lengthSegments; points.add(vectorFromLengthWidth(l, w)); } } + applyCenterOffset(points, start); + } + + /** + * Moves the points generated since {@code start} from around the origin to around the rectangle's center. + */ + private void applyCenterOffset(List points, int start) { + if (centerOffset.x == 0 && centerOffset.y == 0 && centerOffset.z == 0) + return; + for (int i = start; i < points.size(); i++) { + points.get(i).add(centerOffset); + } } + /** + * 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,68 +212,162 @@ 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 + * @param tolerance how far outside the exact shape still counts as contained + * @return {@code true} if the point is inside the rectangle + */ @Override - public boolean contains(Vector3d point) { + protected boolean containsLocal(Vector3d point, double tolerance) { + point.sub(centerOffset); + double l = halfLength + tolerance; + double w = halfWidth + tolerance; return switch (plane) { - case XZ -> Math.abs(point.x) <= halfLength && Math.abs(point.z) <= halfWidth && Math.abs(point.y) < EPSILON; - case XY -> Math.abs(point.x) <= halfLength && Math.abs(point.y) <= halfWidth && Math.abs(point.z) < EPSILON; - case YZ -> Math.abs(point.y) <= halfLength && Math.abs(point.z) <= halfWidth && Math.abs(point.x) < EPSILON; + case XZ -> Math.abs(point.x) <= l && Math.abs(point.z) <= w && Math.abs(point.y) <= tolerance; + case XY -> Math.abs(point.x) <= l && Math.abs(point.y) <= w && Math.abs(point.z) <= tolerance; + case YZ -> Math.abs(point.y) <= l && Math.abs(point.z) <= w && Math.abs(point.x) <= tolerance; }; } + /** + * 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; - if (cornerASupplier != null && cornerBSupplier != null) { - rectangle = new Rectangle(cornerASupplier, cornerBSupplier, plane); - } else { - rectangle = new Rectangle(this.getLength(), this.getWidth(), plane); - } + // copy the current state rather than re-reading the suppliers, so the copy has identical geometry + Rectangle rectangle = new Rectangle(this.getLength(), this.getWidth(), plane); rectangle.centerOffset = new Vector3d(this.centerOffset); + rectangle.cornerASupplier = this.cornerASupplier; + rectangle.cornerBSupplier = this.cornerBSupplier; + rectangle.lastCornerA = this.lastCornerA; + rectangle.lastCornerB = this.lastCornerB; 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..188ce58 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,148 +4,341 @@ 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 { + // The side count is the source of truth; the angle is always derived from it, + // so repeated conversions cannot drift or truncate. + private int sides; 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); + setSideCount(sidesFromAngle(angle)); this.radius = Math.max(radius, Shape.EPSILON); this.height = Math.max(height, 0); } - // --- Static calculation methods --- + /** + * @return the number of sides a polygon with the given exterior angle has, at least 3 + */ + private static int sidesFromAngle(double angle) { + double sides = Math.round(Math.PI * 2 / Math.max(angle, Shape.EPSILON)); + return (int) Math.clamp(sides, 3, 100_000); + } - public static Set calculateRegularPolygon(double radius, double angle, double density, boolean wireframe) { - angle = Math.max(angle, Shape.EPSILON); + private void setSideCount(int sides) { + this.sides = Math.max(sides, 3); + this.angle = Math.PI * 2 / this.sides; + } - Set points = new LinkedHashSet<>(); - double apothem = radius * Math.cos(angle / 2); - double radiusStep = radius / Math.round(apothem / density); + /** + * Appends the outline of a regular polygon: one edge per side, each without its end point, + * so every vertex appears exactly once. + */ + private static void calculatePolygonRing(List points, double radius, int sides, double density) { + double angle = Math.PI * 2 / sides; + Vector3d vertex = new Vector3d(radius, 0, 0); + for (int side = 0; side < sides; side++) { + Line.calculateLine(points, + VectorUtil.rotateAroundY(new Vector3d(vertex), side * angle), + VectorUtil.rotateAroundY(new Vector3d(vertex), (side + 1) * angle), + density, false); + } + } + + // --- Static calculation methods --- + + /** + * 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) { + int sides = sidesFromAngle(angle); if (wireframe) { - radiusStep = 2 * radius; - } else { - points.add(new Vector3d(0, 0, 0)); + calculatePolygonRing(points, radius, sides, density); + return; } - 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( - VectorUtil.rotateAroundY(new Vector3d(vertex), i), - VectorUtil.rotateAroundY(new Vector3d(vertex), i + angle), - density)); - } + // concentric rings from the outline inwards, plus the center + double apothem = radius * Math.cos(Math.PI / sides); + int rings = segments(apothem, density); + for (int ring = rings; ring >= 1; ring--) { + calculatePolygonRing(points, radius * ring / rings, sides, density); } - return points; + points.add(new Vector3d(0, 0, 0)); } - public static Set calculateRegularPrism(double radius, double angle, double height, double density, boolean wireframe) { - Set points = new LinkedHashSet<>(); - Vector3d vertex = new Vector3d(radius, 0, 0); - 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)) { - 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)); + /** + * 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) { + int sides = sidesFromAngle(angle); + double sideAngle = Math.PI * 2 / sides; + // bottom ring + int ringStart = points.size(); + calculatePolygonRing(points, radius, sides, density); + int ringEnd = points.size(); + if (wireframe) { + // top ring + for (int i = ringStart; i < ringEnd; i++) { + Vector3d v = points.get(i); + points.add(new Vector3d(v.x, height, v.z)); + } + // vertical edges, without the vertices that the rings already contain + int layers = segments(height, density); + Vector3d vertex = new Vector3d(radius, 0, 0); + for (int side = 0; side < sides; side++) { + Vector3d corner = VectorUtil.rotateAroundY(new Vector3d(vertex), side * sideAngle); + for (int layer = 1; layer < layers; layer++) { + points.add(new Vector3d(corner.x, height * layer / layers, corner.z)); } } - if (wireframe) - points.addAll(Line.calculateLine(currentVertex, new Vector3d(currentVertex.x, height, currentVertex.z), density)); + } else { + // walls: the ring repeated up to the top + fillVertically(points, ringStart, height, density, true); } - 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); - int sides = getSides(); + double perimeter = sides * getSideLength(); + double area = sides * radius * radius * Math.sin(angle) / 2; return switch (style) { case OUTLINE -> { if (height == 0) - yield 2 * sides * radius * Math.sin(angle / 2) / count; - yield (4 * sides * radius * Math.sin(angle / 2) + height * sides) / count; + yield perimeter / count; + // both rings plus one vertical edge per vertex + yield (2 * perimeter + sides * height) / count; } case SURFACE -> { if (height == 0) - yield Math.sqrt(sides * radius * radius * Math.sin(angle) / 2 / count); - yield (sides * radius * radius * Math.sin(angle) + getSideLength() * sides * height) / count; + yield Math.sqrt(area / count); + // the surface of a prism is its walls + yield Math.sqrt(perimeter * height / count); + } + case FILL -> { + // a flat polygon has no volume: it is filled as a surface + if (height == 0) + yield Math.sqrt(area / count); + yield Math.cbrt(area * height / count); } - case FILL -> (sides * radius * radius * Math.sin(angle) * height) / count; }; } + /** + * 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 + * @param tolerance how far outside the exact shape still counts as contained + * @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; - if (height == 0 && Math.abs(point.y) > EPSILON) return false; - // Check if point is within the polygon on XZ plane using inscribed radius - double dist = Math.sqrt(point.x * point.x + point.z * point.z); + protected boolean containsLocal(Vector3d point, double tolerance) { + if (height > 0) { + if (point.y < -tolerance || point.y > height + tolerance) return false; + } else if (Math.abs(point.y) > tolerance) { + return false; + } + double distance = Math.sqrt(point.x * point.x + point.z * point.z); + if (distance > radius + tolerance) return false; + // Fold the point's angle into a single side: [0, angle), with the side's midpoint at angle / 2. + // The vertices sit at multiples of the angle, and the polygon is symmetric about the x-axis. + double folded = Math.atan2(point.z, point.x) % angle; + if (folded < 0) folded += angle; double apothem = radius * Math.cos(angle / 2); - return dist <= radius && dist <= apothem / Math.cos(Math.atan2(point.z, point.x) % angle - angle / 2); + return distance * Math.cos(folded - angle / 2) <= apothem + tolerance; } + /** + * 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); } - + public int getSides() { return sides; } + + /** + * 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); + setSideCount(sides); 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 +347,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)); + return this.copyTo(new RegularPolygon(sides, 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..c7a4b38 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 @@ -1,13 +1,27 @@ package com.sovdee.shapes.shapes; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.util.VectorUtil; 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 = { @@ -43,7 +57,10 @@ public class RegularPolyhedron extends AbstractShape implements RadialShape, Pol new Quaterniond(0.8090169943749475, 0.0, -0.46708617948135794, 0.35682208977309), new Quaterniond(-0.5, -0.5, -0.6454972243679027, 0.288675134594813), new Quaterniond(0.0, 0.0, 0.9341723589627157, 0.35682208977309), - new Quaterniond(-0.8090169943749475, 0.5, 0.110264089708268, -0.288675134594813) + new Quaterniond(-0.8090169943749475, 0.5, 0.110264089708268, -0.288675134594813), + new Quaterniond(0.8090169943749477, 0.0, 0.11026408970826802, 0.5773502691896257), + new Quaterniond(-0.8090169943749475, -0.5, -0.110264089708268, 0.288675134594813), + new Quaterniond(0.8090169943749475, -0.5, 0.110264089708268, -0.288675134594813) }; private static final Quaterniond[] DODECAHEDRON_FACES = { new Quaterniond(0.0, 0.3090169943749475, 0.0, 0.9510565162951536), @@ -72,6 +89,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 +105,64 @@ 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) { - double step = radius / Math.round(radius / density); - Quaterniond[] rotations = switch (faces) { + public void generateFilled(List points, double density) { + Quaterniond[] rotations = getFaceRotations(); + if (rotations == null) return; + // concentric shells from the surface inwards, plus the center + int shells = segments(radius, density); + for (int shell = shells; shell >= 1; shell--) { + generatePolyhedron(points, rotations, radius * shell / shells, density, SamplingStyle.SURFACE); + } + points.add(new Vector3d(0, 0, 0)); + } + + 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; @@ -135,51 +178,69 @@ private Set generatePolyhedron(Quaterniond[] rotations, double radius, case 20 -> sideLength * ICOSA_INSC; default -> 1; }; + int polyhedronStart = points.size(); 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; + // neighbouring faces share their edges, so those points were generated once per face + VectorUtil.removeDuplicates(points, polyhedronStart, Math.min(density, radius) / 100); } - 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<>(); - 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)); - } - facePoints.add(new Vector3d(0, 0, 0)); - return facePoints; + private void generateFaceSurface(List points, int sides, double radius, double density) { + // a non-wireframe polygon is already filled from its outline down to its center + RegularPolygon.calculateRegularPolygon(points, radius, 2 * Math.PI / sides, density, false); } @Override public double computeDensity(SamplingStyle style, int targetPointCount) { - return 0.25; // No good formula available + int count = Math.max(targetPointCount, 1); + int faceSides = faces == 12 ? 5 : 3; + double sideLength = getSideLength(); + double inscribedRadius = sideLength * switch (faces) { + case 4 -> TETRA_INSC; + case 8 -> OCTA_INSC; + case 12 -> DODECA_INSC; + case 20 -> ICOSA_INSC; + default -> 0; + }; + // every edge is shared by exactly two faces + int edges = faces * faceSides / 2; + double faceArea = faceSides * sideLength * sideLength / (4 * Math.tan(Math.PI / faceSides)); + return switch (style) { + case OUTLINE -> edges * sideLength / count; + case SURFACE -> Math.sqrt(faces * faceArea / count); + case FILL -> Math.cbrt(faces * faceArea * inscribedRadius / 3 / count); + }; } @Override - public boolean contains(Vector3d point) { - // Conservative: check if within inscribed sphere - double sideLength = getSideLength(); - double inscribedRadius = switch (faces) { - case 4 -> sideLength * TETRA_INSC; - case 8 -> sideLength * OCTA_INSC; - case 12 -> sideLength * DODECA_INSC; - case 20 -> sideLength * ICOSA_INSC; + protected boolean containsLocal(Vector3d point, double tolerance) { + Quaterniond[] rotations = getFaceRotations(); + if (rotations == null) return false; + double inscribedRadius = getSideLength() * switch (faces) { + case 4 -> TETRA_INSC; + case 8 -> OCTA_INSC; + case 12 -> DODECA_INSC; + case 20 -> ICOSA_INSC; default -> 0; }; - return point.length() <= inscribedRadius; + // a convex polyhedron is the space behind all of its faces + Vector3d normal = new Vector3d(); + for (Quaterniond rotation : rotations) { + rotation.transform(normal.set(0, 1, 0)); + if (point.dot(normal) > inscribedRadius + tolerance) + return false; + } + return true; } @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..017d1bc 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,164 @@ 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 the volume of this shape. + * The point is in the same space as the points returned by {@link PointSampler#getPoints(Shape)}: + * the shape's orientation, scale and offset are taken into account. + *

+ * The volume is that of the solid shape, regardless of sampling style. Shapes with no volume of their own + * (curves and flat shapes) are treated as being {@link #EPSILON} thick. + * + * @param point the point to test + * @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); + + /** + * Re-reads any external inputs (such as suppliers) and updates the geometry if they changed. + * Implementations must change {@link #getVersion()} if, and only if, the geometry changed. + * Called by the {@link PointSampler} before every sampling, so it should be cheap when nothing changed. + */ + default void refresh() {} /** * Called by PointSampler before point generation. Override for supplier refresh, step recalc, etc. @@ -59,7 +186,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 +195,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..b431d49 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,35 +59,38 @@ 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) { - int subSpheres = (int) (radius / density) - 1; - double radiusStep = radius / subSpheres; - for (int i = 1; i < subSpheres; i++) { - double subRadius = i * radiusStep; - int pointCount = 4 * (int) (Math.PI * subRadius * subRadius / (density * density)); - points.addAll(calculateFibonacciSphere(pointCount, subRadius, cutoffAngle)); + public void generateFilled(List points, double density) { + // concentric shells from the surface inwards, plus the center + int shells = (int) Math.min(Math.ceil(radius / density), Integer.MAX_VALUE - 1); + if (shells < 1) shells = 1; + for (int i = 1; i <= shells; i++) { + double subRadius = radius * i / shells; + int pointCount = Math.max(4 * (int) (Math.PI * subRadius * subRadius / (density * density)), 1); + calculateFibonacciSphere(points, pointCount, subRadius, cutoffAngle); } + points.add(new Vector3d(0, 0, 0)); } @Override @@ -101,8 +103,9 @@ public double computeDensity(SamplingStyle style, int targetPointCount) { } @Override - public boolean contains(Vector3d point) { - return point.x * point.x + point.y * point.y + point.z * point.z <= radius * radius; + protected boolean containsLocal(Vector3d point, double tolerance) { + double maxDistance = radius + tolerance; + return point.lengthSquared() <= maxDistance * maxDistance; } @Override diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/SphericalCap.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/SphericalCap.java index fc290a0..a2f6c6a 100644 --- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/SphericalCap.java +++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/SphericalCap.java @@ -23,11 +23,12 @@ public void setCutoffAngle(double cutoffAngle) { } @Override - public boolean contains(Vector3d point) { - if (!super.contains(point)) return false; - // Check if within the cap's polar angle - double y = point.y / getRadius(); - return y >= cutoffAngleCos; + protected boolean containsLocal(Vector3d point, double tolerance) { + if (!super.containsLocal(point, tolerance)) return false; + // The solid form of a cap is a spherical sector: everything within the cutoff angle of the y-axis. + double distance = point.length(); + if (distance <= tolerance) return true; + return point.y >= distance * cutoffAngleCos - tolerance; } @Override diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Star.java b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Star.java index c0a262d..4eac0d7 100644 --- a/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Star.java +++ b/shapes-lib/src/main/java/com/sovdee/shapes/shapes/Star.java @@ -4,61 +4,109 @@ 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; public class Star extends AbstractShape { private double innerRadius; private double outerRadius; + // The point count is the source of truth; the angle between points is always derived from it. + private int starPoints; private double angle; public Star(double innerRadius, double outerRadius, double angle) { super(); this.innerRadius = Math.max(innerRadius, Shape.EPSILON); this.outerRadius = Math.max(outerRadius, Shape.EPSILON); - this.angle = Math.clamp(angle, Shape.EPSILON, Math.PI); + double points = Math.round(Math.PI * 2 / Math.clamp(angle, Shape.EPSILON, Math.PI)); + setPointCount((int) Math.clamp(points, 2, 100_000)); } - private static Set calculateStar(double innerRadius, double outerRadius, double angle, double density) { - Set points = new LinkedHashSet<>(); + /** + * Creates a star with the given number of points. + * + * @param innerRadius the radius of the inner vertices; clamped to at least {@link Shape#EPSILON} + * @param outerRadius the radius of the outer vertices; clamped to at least {@link Shape#EPSILON} + * @param starPoints the number of points on the star; at least 2 + */ + public Star(double innerRadius, double outerRadius, int starPoints) { + super(); + this.innerRadius = Math.max(innerRadius, Shape.EPSILON); + this.outerRadius = Math.max(outerRadius, Shape.EPSILON); + setPointCount(starPoints); + } + + private void setPointCount(int starPoints) { + this.starPoints = Math.max(starPoints, 2); + this.angle = Math.PI * 2 / this.starPoints; + } + + private static void calculateStar(List points, double innerRadius, double outerRadius, int starPoints, double density) { + double angle = Math.PI * 2 / starPoints; Vector3d outerVertex = new Vector3d(outerRadius, 0, 0); Vector3d innerVertex = new Vector3d(innerRadius, 0, 0); - for (double theta = 0; theta < 2 * Math.PI; theta += angle) { - Vector3d currentVertex = VectorUtil.rotateAroundY(new Vector3d(outerVertex), theta); - points.addAll(Line.calculateLine(currentVertex, VectorUtil.rotateAroundY(new Vector3d(innerVertex), theta + angle / 2), density)); - points.addAll(Line.calculateLine(currentVertex, VectorUtil.rotateAroundY(new Vector3d(innerVertex), theta - angle / 2), density)); + // walk the outline once: outer vertex, inner vertex, next outer vertex... + // each edge leaves out its end point, so every vertex appears exactly once. + for (int point = 0; point < starPoints; point++) { + Vector3d outer = VectorUtil.rotateAroundY(new Vector3d(outerVertex), point * angle); + Vector3d inner = VectorUtil.rotateAroundY(new Vector3d(innerVertex), (point + 0.5) * angle); + Vector3d nextOuter = VectorUtil.rotateAroundY(new Vector3d(outerVertex), (point + 1) * angle); + Line.calculateLine(points, outer, inner, density, false); + Line.calculateLine(points, inner, nextOuter, density, false); } - return points; } @Override - public void generateOutline(Set points, double density) { - points.addAll(calculateStar(innerRadius, outerRadius, angle, density)); + public void generateOutline(List points, double density) { + calculateStar(points, innerRadius, outerRadius, starPoints, density); } @Override - public void generateSurface(Set points, double density) { + public void generateSurface(List points, double density) { + // concentric stars, shrinking both radii until the smaller one reaches the center double minRadius = Math.min(innerRadius, outerRadius); - for (double r = 0; r < minRadius; r += density) { - points.addAll(calculateStar(innerRadius - r, outerRadius - r, angle, density)); + int rings = segments(minRadius, density); + for (int ring = 0; ring < rings; ring++) { + double r = minRadius * ring / rings; + calculateStar(points, innerRadius - r, outerRadius - r, starPoints, density); } + points.add(new Vector3d(0, 0, 0)); } @Override public double computeDensity(SamplingStyle style, int targetPointCount) { int count = Math.max(targetPointCount, 1); - double sideLength = Math.sqrt(Math.pow(innerRadius, 2) + Math.pow(outerRadius, 2) - 2 * innerRadius * outerRadius * Math.cos(angle)); - double perimeter = sideLength * getStarPoints() * 2; - return perimeter / count; + // neighbouring inner and outer vertices are half a point-angle apart + double sideLength = Math.sqrt(innerRadius * innerRadius + outerRadius * outerRadius + - 2 * innerRadius * outerRadius * Math.cos(angle / 2)); + double perimeter = sideLength * starPoints * 2; + return switch (style) { + case OUTLINE -> perimeter / count; + case SURFACE, FILL -> { + // The surface is drawn as concentric outlines one density apart, so the point count is roughly + // (perimeter / density) * (rings / 2), with rings = minRadius / density. + double minRadius = Math.min(innerRadius, outerRadius); + double maxRadius = Math.max(innerRadius, outerRadius); + // the outlines shrink by minRadius in total; average their perimeter over that range + double innerPerimeter = (maxRadius - minRadius) * starPoints * 2; + double averagePerimeter = (perimeter + innerPerimeter) / 2; + yield Math.sqrt(averagePerimeter * minRadius / count); + } + }; } @Override - public boolean contains(Vector3d point) { - if (Math.abs(point.y) > EPSILON) return false; - // Check if within outer radius bounding circle - double dist = Math.sqrt(point.x * point.x + point.z * point.z); - return dist <= Math.max(innerRadius, outerRadius); + protected boolean containsLocal(Vector3d point, double tolerance) { + if (Math.abs(point.y) > tolerance) return false; + // the outline as a polygon: outer and inner vertices in turn + List outline = new ArrayList<>(starPoints * 2); + for (int i = 0; i < starPoints; i++) { + outline.add(VectorUtil.rotateAroundY(new Vector3d(outerRadius, 0, 0), i * angle)); + outline.add(VectorUtil.rotateAroundY(new Vector3d(innerRadius, 0, 0), (i + 0.5) * angle)); + } + return VectorUtil.isInPolygonXZ(outline, point.x, point.z) + || VectorUtil.isNearPolygonXZ(outline, point.x, point.z, tolerance); } public double getInnerRadius() { return innerRadius; } @@ -76,17 +124,16 @@ public void setOuterRadius(double outerRadius) { } public int getStarPoints() { - return (int) (Math.PI * 2 / angle); + return starPoints; } public void setStarPoints(int starPoints) { - starPoints = Math.max(starPoints, 2); - this.angle = Math.PI * 2 / starPoints; + setPointCount(starPoints); invalidate(); } @Override public Shape clone() { - return this.copyTo(new Star(innerRadius, outerRadius, angle)); + return this.copyTo(new Star(innerRadius, outerRadius, starPoints)); } } diff --git a/shapes-lib/src/main/java/com/sovdee/shapes/util/VectorUtil.java b/shapes-lib/src/main/java/com/sovdee/shapes/util/VectorUtil.java index 8519212..3b257fb 100644 --- a/shapes-lib/src/main/java/com/sovdee/shapes/util/VectorUtil.java +++ b/shapes-lib/src/main/java/com/sovdee/shapes/util/VectorUtil.java @@ -33,19 +33,91 @@ public static Vector3d rotateAroundY(Vector3d v, double angle) { /** * Transforms a list of vectors using a quaternion, modifying them in place. */ - public static List transform(Quaterniond quaternion, List vectors) { - vectors.replaceAll(quaternion::transform); - return vectors; + public static void transform(Quaterniond quaternion, List vectors) { + transform(quaternion, vectors, 0); } /** - * Transforms a set of vectors using a quaternion, returning a new set. + * Transforms vectors in a list from the given start index, modifying them in place. */ - public static Set transform(Quaterniond quaternion, Set vectors) { - Set newVectors = new HashSet<>(); - for (Vector3d vector : vectors) { - newVectors.add(quaternion.transform(new Vector3d(vector))); + public static void transform(Quaterniond quaternion, List vectors, int fromIndex) { + for (int i = fromIndex; i < vectors.size(); i++) { + quaternion.transform(vectors.get(i)); } - return newVectors; } + + + /** + * Removes points from index {@code fromIndex} onwards that lie within roughly {@code precision} of an + * earlier point in that same range. The first occurrence is kept, and the order of the remaining points + * is unchanged. + * + * @param points the list to remove duplicates from + * @param fromIndex the index of the first point to consider + * @param precision the size of the grid that points are snapped to when compared + */ + public static void removeDuplicates(List points, int fromIndex, double precision) { + Set seen = new HashSet<>(); + int write = fromIndex; + for (int read = fromIndex; read < points.size(); read++) { + Vector3d v = points.get(read); + Key key = new Key(Math.round(v.x / precision), Math.round(v.y / precision), Math.round(v.z / precision)); + if (seen.add(key)) + points.set(write++, v); + } + points.subList(write, points.size()).clear(); + } + + /** + * Checks whether a point is inside a closed polygon on the XZ plane, using ray casting. + * The y components of the vertices are ignored. + */ + public static boolean isInPolygonXZ(List vertices, double x, double z) { + boolean inside = false; + int n = vertices.size(); + for (int i = 0, j = n - 1; i < n; j = i++) { + double xi = vertices.get(i).x, zi = vertices.get(i).z; + double xj = vertices.get(j).x, zj = vertices.get(j).z; + if ((zi > z) != (zj > z) && x < (xj - xi) * (z - zi) / (zj - zi) + xi) + inside = !inside; + } + return inside; + } + + /** + * Checks whether a point on the XZ plane is within {@code tolerance} of the outline of a closed polygon. + * The y components of the vertices are ignored. + */ + public static boolean isNearPolygonXZ(List vertices, double x, double z, double tolerance) { + Vector3d point = new Vector3d(x, 0, z); + Vector3d a = new Vector3d(); + Vector3d b = new Vector3d(); + int n = vertices.size(); + for (int i = 0, j = n - 1; i < n; j = i++) { + a.set(vertices.get(i).x, 0, vertices.get(i).z); + b.set(vertices.get(j).x, 0, vertices.get(j).z); + if (distanceToSegmentSquared(point, a, b) <= tolerance * tolerance) + return true; + } + return false; + } + + /** + * @return the squared distance between a point and the line segment from {@code a} to {@code b} + */ + public static double distanceToSegmentSquared(Vector3d point, Vector3d a, Vector3d b) { + double abx = b.x - a.x, aby = b.y - a.y, abz = b.z - a.z; + double lengthSquared = abx * abx + aby * aby + abz * abz; + double t = 0; + if (lengthSquared > 0) { + t = ((point.x - a.x) * abx + (point.y - a.y) * aby + (point.z - a.z) * abz) / lengthSquared; + t = Math.clamp(t, 0.0, 1.0); + } + double dx = a.x + abx * t - point.x; + double dy = a.y + aby * t - point.y; + double dz = a.z + abz * t - point.z; + return dx * dx + dy * dy + dz * dz; + } + + private record Key(long x, long y, long z) {} } diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/EasingFunctionTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/EasingFunctionTest.java new file mode 100644 index 0000000..988dfff --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/EasingFunctionTest.java @@ -0,0 +1,169 @@ +package com.sovdee.shapes.modifiers; + +import com.sovdee.shapes.modifiers.EasingFunction.Mode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Every eased modifier and gradient runs its input through an easing, once per point. + */ +class EasingFunctionTest { + + private static final int STEPS = 1000; + + private static Map easings() { + Map m = new LinkedHashMap<>(); + m.put("LINEAR", EasingFunction.LINEAR); + m.put("QUAD_IN", EasingFunction.QUAD_IN); + m.put("QUAD_OUT", EasingFunction.QUAD_OUT); + m.put("QUAD_IN_OUT", EasingFunction.QUAD_IN_OUT); + m.put("CUBIC_IN", EasingFunction.CUBIC_IN); + m.put("CUBIC_OUT", EasingFunction.CUBIC_OUT); + m.put("CUBIC_IN_OUT", EasingFunction.CUBIC_IN_OUT); + m.put("SINE_IN", EasingFunction.SINE_IN); + m.put("SINE_OUT", EasingFunction.SINE_OUT); + m.put("SINE_IN_OUT", EasingFunction.SINE_IN_OUT); + m.put("powerIn(5)", EasingFunction.powerIn(5)); + m.put("powerOut(0.5)", EasingFunction.powerOut(0.5)); + m.put("powerInOut(4)", EasingFunction.powerInOut(4)); + return m; + } + + static Stream all() { + return easings().entrySet().stream().map(e -> Arguments.of(e.getKey(), e.getValue())); + } + + static Stream inOut() { + return Stream.of( + Arguments.of("QUAD_IN_OUT", EasingFunction.QUAD_IN_OUT), + Arguments.of("CUBIC_IN_OUT", EasingFunction.CUBIC_IN_OUT), + Arguments.of("SINE_IN_OUT", EasingFunction.SINE_IN_OUT), + Arguments.of("powerInOut(4)", EasingFunction.powerInOut(4)), + Arguments.of("powerInOut(1.5)", EasingFunction.powerInOut(1.5))); + } + + // ----- properties every easing must have ----- + + @ParameterizedTest(name = "{0}") + @MethodSource("all") + void startsAtZeroAndEndsAtOne(String name, EasingFunction f) { + assertEquals(0.0, f.apply(0.0), 1e-12, name + "(0)"); + assertEquals(1.0, f.apply(1.0), 1e-12, name + "(1)"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("all") + void neverDecreases(String name, EasingFunction f) { + double previous = f.apply(0); + for (int i = 1; i <= STEPS; i++) { + double t = i / (double) STEPS; + double v = f.apply(t); + assertTrue(v >= previous - 1e-12, name + " decreases at t=" + t + ": " + previous + " -> " + v); + previous = v; + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("all") + void staysWithinZeroToOne(String name, EasingFunction f) { + for (int i = 0; i <= STEPS; i++) { + double v = f.apply(i / (double) STEPS); + assertTrue(v >= -1e-12 && v <= 1 + 1e-12, name + " left [0, 1]: " + v); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("inOut") + void inOut_isSymmetricAroundTheMidpoint(String name, EasingFunction f) { + assertEquals(0.5, f.apply(0.5), 1e-12, name + "(0.5)"); + for (int i = 0; i <= STEPS; i++) { + double t = i / (double) STEPS; + assertEquals(1.0, f.apply(t) + f.apply(1 - t), 1e-9, name + " is not symmetric at t=" + t); + } + } + + // ----- specific curves ----- + + @Test + void out_mirrorsIn() { + EasingFunction[][] pairs = { + {EasingFunction.QUAD_IN, EasingFunction.QUAD_OUT}, + {EasingFunction.CUBIC_IN, EasingFunction.CUBIC_OUT}, + {EasingFunction.SINE_IN, EasingFunction.SINE_OUT}, + {EasingFunction.powerIn(3.5), EasingFunction.powerOut(3.5)}, + }; + for (EasingFunction[] pair : pairs) { + for (int i = 0; i <= STEPS; i++) { + double t = i / (double) STEPS; + assertEquals(1 - pair[0].apply(1 - t), pair[1].apply(t), 1e-9, pair[0] + " vs " + pair[1] + " at " + t); + } + } + } + + @Test + void linear_isTheIdentity() { + for (int i = 0; i <= 100; i++) { + double t = i / 100.0; + assertEquals(t, EasingFunction.LINEAR.apply(t), 1e-12); + } + } + + @Test + void exponentOne_isLinearInEveryMode() { + for (Mode mode : Mode.values()) { + EasingFunction f = new EasingFunction.PowerEasing(1, mode); + for (int i = 0; i <= 100; i++) + assertEquals(i / 100.0, f.apply(i / 100.0), 1e-12, mode.toString()); + } + } + + @Test + void knownValues() { + assertEquals(0.25, EasingFunction.QUAD_IN.apply(0.5), 1e-12); + assertEquals(0.75, EasingFunction.QUAD_OUT.apply(0.5), 1e-12); + assertEquals(0.125, EasingFunction.CUBIC_IN.apply(0.5), 1e-12); + assertEquals(0.875, EasingFunction.CUBIC_OUT.apply(0.5), 1e-12); + assertEquals(0.125, EasingFunction.QUAD_IN_OUT.apply(0.25), 1e-12); + assertEquals(1 - Math.cos(Math.PI / 4), EasingFunction.SINE_IN.apply(0.5), 1e-12); + assertEquals(Math.sin(Math.PI / 4), EasingFunction.SINE_OUT.apply(0.5), 1e-12); + } + + @Test + void easeIn_isSlowerThanLinearAndEaseOutFaster() { + for (int i = 1; i < 100; i++) { + double t = i / 100.0; + assertTrue(EasingFunction.QUAD_IN.apply(t) < t); + assertTrue(EasingFunction.QUAD_OUT.apply(t) > t); + assertTrue(EasingFunction.SINE_IN.apply(t) < t); + assertTrue(EasingFunction.SINE_OUT.apply(t) > t); + } + } + + // ----- hashes (used in geometry cache keys) ----- + + @Test + void easingHash_isEqualForEqualConfigurations() { + assertEquals(EasingFunction.QUAD_IN.easingHash(), EasingFunction.powerIn(2).easingHash()); + assertEquals(EasingFunction.CUBIC_IN_OUT.easingHash(), EasingFunction.powerInOut(3).easingHash()); + assertEquals(EasingFunction.SINE_OUT.easingHash(), new EasingFunction.SineEasing(Mode.OUT).easingHash()); + } + + @Test + void easingHash_differsBetweenDifferentCurves() { + Map all = easings(); + Set hashes = new HashSet<>(); + for (EasingFunction f : all.values()) + hashes.add(f.easingHash()); + assertEquals(all.size(), hashes.size(), "two different easings share a hash: " + all.keySet()); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ScalingModifierTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ScalingModifierTest.java new file mode 100644 index 0000000..3285412 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ScalingModifierTest.java @@ -0,0 +1,126 @@ +package com.sovdee.shapes.modifiers; + +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ScalingModifierTest { + + private PointContext ctx(double x, double y, double z, List allPoints) { + ShapeBounds bounds = ShapeBounds.compute(allPoints); + PointContext c = new PointContext(); + c.bounds = bounds; + c.x = x; c.y = y; c.z = z; + return c; + } + + /** Points spanning Y=[0,1] so normalizedY works predictably. */ + private List ySpan() { + return List.of(new Vector3d(0, 0, 0), new Vector3d(0, 1, 0)); + } + + /** Points spanning X=[0,1]. */ + private List xSpan() { + return List.of(new Vector3d(0, 0, 0), new Vector3d(1, 0, 0)); + } + + @Test + void scalesAtInputMin() { + ScalingModifier mod = new ScalingModifier(0.5, 2.0, StandardInput.Y, ScaleAxes.XZ); + PointContext c = ctx(1, 0, 1, ySpan()); + mod.modify(c); + assertEquals(0.5, c.x, 1e-9, "At normalizedY=0, scale=startScale=0.5"); + assertEquals(0.5, c.z, 1e-9); + assertEquals(0.0, c.y, 1e-9, "Y should be unchanged"); + } + + @Test + void scalesAtInputMax() { + ScalingModifier mod = new ScalingModifier(0.5, 2.0, StandardInput.Y, ScaleAxes.XZ); + PointContext c = ctx(1, 1, 1, ySpan()); + mod.modify(c); + assertEquals(2.0, c.x, 1e-9, "At normalizedY=1, scale=endScale=2.0"); + assertEquals(2.0, c.z, 1e-9); + } + + @Test + void scalesAtMidpoint() { + ScalingModifier mod = new ScalingModifier(0.0, 2.0, StandardInput.Y, ScaleAxes.XZ); + PointContext c = ctx(1, 0.5, 1, ySpan()); + mod.modify(c); + double expected = 0.0 + 0.5 * (2.0 - 0.0); // lerp(0, 2, 0.5) = 1 + assertEquals(expected, c.x, 1e-9); + assertEquals(expected, c.z, 1e-9); + } + + @Test + void xzAxes_yUnchanged() { + ScalingModifier mod = new ScalingModifier(1.0, 3.0, StandardInput.Y, ScaleAxes.XZ); + PointContext c = ctx(2, 0.5, -3, ySpan()); + double origY = c.y; + mod.modify(c); + assertEquals(origY, c.y, 1e-9, "Y coordinate must not be changed when scaling XZ"); + } + + @Test + void xzAxes_drivenByX() { + ScalingModifier mod = new ScalingModifier(0.0, 2.0, StandardInput.X, ScaleAxes.XZ); + PointContext c = ctx(1, 1, 1, xSpan()); // normalizedX=1 → scale=2.0 + mod.modify(c); + assertEquals(2.0, c.x, 1e-9, "X scaled by endScale=2.0 at normalizedX=1"); + assertEquals(2.0, c.z, 1e-9, "Z scaled by endScale=2.0"); + assertEquals(1.0, c.y, 1e-9, "Y unchanged when scaling XZ"); + } + + @Test + void xyzAxes_allScaled() { + ScalingModifier mod = new ScalingModifier(2.0, 2.0, StandardInput.Y, ScaleAxes.XYZ); + PointContext c = ctx(1, 0.5, 1, ySpan()); + mod.modify(c); + assertEquals(2.0, c.x, 1e-9); + assertEquals(1.0, c.y, 1e-9, "Y also scaled at 0.5 position"); + assertEquals(2.0, c.z, 1e-9); + } + + @Test + void yzAxes_xUnchanged() { + ScalingModifier mod = new ScalingModifier(2.0, 2.0, StandardInput.Y, ScaleAxes.YZ); + PointContext c = ctx(3, 0.5, 1, ySpan()); + mod.modify(c); + assertEquals(3.0, c.x, 1e-9, "X unchanged when scaling YZ"); + } + + @Test + void modifierHashChangesWithParams() { + ScalingModifier a = new ScalingModifier(1.0, 2.0, StandardInput.Y, ScaleAxes.XZ); + ScalingModifier b = new ScalingModifier(1.5, 2.0, StandardInput.Y, ScaleAxes.XZ); + assertNotEquals(a.modifierHash(), b.modifierHash(), "Different startScale"); + } + + @Test + void modifierHashChangesWithInput() { + ScalingModifier y = new ScalingModifier(1.0, 2.0, StandardInput.Y, ScaleAxes.XZ); + ScalingModifier x = new ScalingModifier(1.0, 2.0, StandardInput.X, ScaleAxes.XZ); + ScalingModifier t = new ScalingModifier(1.0, 2.0, StandardInput.T, ScaleAxes.XZ); + assertNotEquals(y.modifierHash(), x.modifierHash(), "Y vs X input"); + assertNotEquals(y.modifierHash(), t.modifierHash(), "Y vs T input"); + } + + @Test + void modifierHashChangesWithAxes() { + ScalingModifier xz = new ScalingModifier(1.0, 2.0, StandardInput.Y, ScaleAxes.XZ); + ScalingModifier xyz = new ScalingModifier(1.0, 2.0, StandardInput.Y, ScaleAxes.XYZ); + assertNotEquals(xz.modifierHash(), xyz.modifierHash(), "XZ vs XYZ axes"); + } + + @Test + void cloneIsIndependent() { + ScalingModifier original = new ScalingModifier(1.0, 2.0, StandardInput.Y, ScaleAxes.XZ); + ScalingModifier copy = original.clone(); + copy.setStartScale(99.0); + assertEquals(1.0, original.getStartScale(), "Mutating clone must not affect original"); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ShapeBoundsTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ShapeBoundsTest.java new file mode 100644 index 0000000..6308748 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/ShapeBoundsTest.java @@ -0,0 +1,160 @@ +package com.sovdee.shapes.modifiers; + +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ShapeBoundsTest { + + @Test + void computeEmpty() { + ShapeBounds b = ShapeBounds.compute(List.of()); + assertEquals(0.0, b.minX()); + assertEquals(0.0, b.maxX()); + assertEquals(0.0, b.invRangeX()); + assertEquals(0.0, b.invRangeY()); + assertEquals(0.0, b.invRangeZ()); + assertEquals(0.0, b.maxRadiusXZ()); + } + + @Test + void computeSinglePoint() { + ShapeBounds b = ShapeBounds.compute(List.of(new Vector3d(3, 5, 7))); + assertEquals(3.0, b.minX()); + assertEquals(3.0, b.maxX()); + assertEquals(0.0, b.invRangeX(), "invRangeX should be 0 for zero-width extent"); + assertEquals(0.0, b.invRangeY()); + assertEquals(0.0, b.invRangeZ()); + } + + @Test + void computeMultiplePoints() { + List pts = List.of( + new Vector3d(-1, 0, 2), + new Vector3d(3, 4, -2), + new Vector3d(0, 2, 0) + ); + ShapeBounds b = ShapeBounds.compute(pts); + assertEquals(-1.0, b.minX()); + assertEquals(3.0, b.maxX()); + assertEquals(0.0, b.minY()); + assertEquals(4.0, b.maxY()); + assertEquals(-2.0, b.minZ()); + assertEquals(2.0, b.maxZ()); + assertEquals(4.0, b.width()); + assertEquals(4.0, b.height()); + assertEquals(4.0, b.length()); + } + + @Test + void normalizeXYZ() { + List pts = List.of(new Vector3d(0, 0, 0), new Vector3d(2, 4, 6)); + ShapeBounds b = ShapeBounds.compute(pts); + + assertEquals(0.0, b.normalizeX(0.0), 1e-9); + assertEquals(1.0, b.normalizeX(2.0), 1e-9); + assertEquals(0.5, b.normalizeX(1.0), 1e-9); + + assertEquals(0.5, b.normalizeY(2.0), 1e-9); + assertEquals(0.5, b.normalizeZ(3.0), 1e-9); + } + + @Test + void normalizeRadial() { + // Points on a unit circle in XZ + List pts = List.of( + new Vector3d(1, 0, 0), + new Vector3d(-1, 0, 0), + new Vector3d(0, 0, 1), + new Vector3d(0, 0, -1) + ); + ShapeBounds b = ShapeBounds.compute(pts); + assertEquals(1.0, b.maxRadiusXZ(), 1e-9); + // Point on the unit circle → normalized radial = 1 + assertEquals(1.0, b.normalizeRadial(1, 0), 1e-9); + // Origin → normalized radial = 0 + assertEquals(0.0, b.normalizeRadial(0, 0), 1e-9); + // Point at (1/√2, 1/√2) in XZ: radius = sqrt(0.5 + 0.5) = 1.0 → normalizes to 1.0 + assertEquals(1.0, b.normalizeRadial(1.0 / Math.sqrt(2), 1.0 / Math.sqrt(2)), 1e-6); + // Point at half-radius on X axis + assertEquals(0.5, b.normalizeRadial(0.5, 0), 1e-9); + } + + @Test + void zeroDimensionNormalization() { + // Flat shape — all points at Y=5 + List pts = List.of( + new Vector3d(0, 5, 0), + new Vector3d(1, 5, 0), + new Vector3d(0, 5, 1) + ); + ShapeBounds b = ShapeBounds.compute(pts); + assertEquals(0.0, b.invRangeY(), "Flat shape should have invRangeY=0"); + // normalizeY should return 0 (no divide-by-zero) + assertEquals(0.0, b.normalizeY(5.0), 1e-9); + assertEquals(0.0, b.normalizeY(0.0), 1e-9); + } + + @Test + void maxRadiusXZ_ignoresY() { + // (1, 100, 0) → XZ radius = 1; (0, -100, 1) → XZ radius = 1 + // A huge Y value does not contribute to the max XZ radius + List pts = List.of( + new Vector3d(1, 100, 0), + new Vector3d(0, -100, 1) + ); + ShapeBounds b = ShapeBounds.compute(pts); + assertEquals(1.0, b.maxRadiusXZ(), 1e-9, "Max XZ radius should only use X and Z components"); + + // Additional check: a point with larger XZ distance should increase maxRadiusXZ + List pts2 = List.of( + new Vector3d(3, 0, 4), // XZ radius = 5 + new Vector3d(0, 1000, 0) // XZ radius = 0 + ); + ShapeBounds b2 = ShapeBounds.compute(pts2); + assertEquals(5.0, b2.maxRadiusXZ(), 1e-9); + } + + @Test + void maxDistance_usesAllThreeAxes() { + List pts = List.of( + new Vector3d(1, 0, 0), + new Vector3d(0, 10, 0), + new Vector3d(2, -3, 6) // distance 7 + ); + ShapeBounds b = ShapeBounds.compute(pts); + assertEquals(10.0, b.maxDistance(), 1e-9); + assertEquals(Math.sqrt(40), b.maxRadiusXZ(), 1e-9, "the XZ radius ignores y"); + } + + @Test + void normalizedSpherical_staysWithinZeroToOne() { + // a tall, thin shape: far taller than it is wide + List pts = List.of( + new Vector3d(1, 0, 0), + new Vector3d(0, 0, 1), + new Vector3d(1, 10, 0), + new Vector3d(0, 5, 0) + ); + PointContext ctx = new PointContext(); + ctx.bounds = ShapeBounds.compute(pts); + double furthest = 0; + for (Vector3d p : pts) { + ctx.x = p.x; ctx.y = p.y; ctx.z = p.z; + double t = ctx.normalizedSpherical(); + assertTrue(t >= 0 && t <= 1 + 1e-12, "normalized spherical distance out of range: " + t); + furthest = Math.max(furthest, t); + } + assertEquals(1.0, furthest, 1e-12, "the furthest point should normalize to 1"); + } + + @Test + void normalizedSpherical_isZeroForASinglePointAtTheOrigin() { + PointContext ctx = new PointContext(); + ctx.bounds = ShapeBounds.compute(List.of(new Vector3d(0, 0, 0))); + assertEquals(0.0, ctx.normalizedSpherical(), 1e-12); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/StandardInputTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/StandardInputTest.java new file mode 100644 index 0000000..d61faf8 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/StandardInputTest.java @@ -0,0 +1,146 @@ +package com.sovdee.shapes.modifiers; + +import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.Cuboid; +import com.sovdee.shapes.shapes.Helix; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.shapes.shapes.Sphere; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The inputs every scale/twist/wave modifier and every gradient is driven by. + */ +class StandardInputTest { + + private static PointContext at(ShapeBounds bounds, double x, double y, double z) { + PointContext ctx = new PointContext(); + ctx.bounds = bounds; + ctx.x = x; + ctx.y = y; + ctx.z = z; + return ctx; + } + + private static final ShapeBounds BOX = ShapeBounds.compute(List.of( + new Vector3d(-2, -1, 0), new Vector3d(2, 3, 4))); + + @Test + void xyz_mapTheBoundsOntoZeroToOne() { + assertEquals(0.0, StandardInput.X.sample(at(BOX, -2, 0, 0)), 1e-12); + assertEquals(1.0, StandardInput.X.sample(at(BOX, 2, 0, 0)), 1e-12); + assertEquals(0.5, StandardInput.X.sample(at(BOX, 0, 0, 0)), 1e-12); + assertEquals(0.0, StandardInput.Y.sample(at(BOX, 0, -1, 0)), 1e-12); + assertEquals(1.0, StandardInput.Y.sample(at(BOX, 0, 3, 0)), 1e-12); + assertEquals(0.25, StandardInput.Y.sample(at(BOX, 0, 0, 0)), 1e-12); + assertEquals(0.0, StandardInput.Z.sample(at(BOX, 0, 0, 0)), 1e-12); + assertEquals(1.0, StandardInput.Z.sample(at(BOX, 0, 0, 4)), 1e-12); + } + + @Test + void xyz_eachIgnoreTheOtherAxes() { + assertEquals(StandardInput.X.sample(at(BOX, 1, -1, 0)), StandardInput.X.sample(at(BOX, 1, 3, 4)), 1e-12); + assertEquals(StandardInput.Y.sample(at(BOX, -2, 1, 0)), StandardInput.Y.sample(at(BOX, 2, 1, 4)), 1e-12); + assertEquals(StandardInput.Z.sample(at(BOX, -2, -1, 2)), StandardInput.Z.sample(at(BOX, 2, 3, 2)), 1e-12); + } + + @Test + void t_followsTheDrawOrder() { + PointContext ctx = at(BOX, 0, 0, 0); + ctx.totalPoints = 10; + ctx.index = 0; + assertEquals(0.0, StandardInput.T.sample(ctx), 1e-12); + ctx.index = 5; + assertEquals(0.5, StandardInput.T.sample(ctx), 1e-12); + // index / totalPoints: the last point stops just short of 1, so periodic inputs don't repeat the first point + ctx.index = 9; + assertEquals(0.9, StandardInput.T.sample(ctx), 1e-12); + } + + @Test + void t_isZeroWithoutAPointCount() { + PointContext ctx = at(BOX, 0, 0, 0); + ctx.totalPoints = 0; + ctx.index = 3; + assertEquals(0.0, StandardInput.T.sample(ctx), 1e-12); + } + + @Test + void radius_isTheHorizontalDistanceFromTheYAxis() { + ShapeBounds bounds = ShapeBounds.compute(List.of(new Vector3d(3, 0, 4), new Vector3d(0, 10, 0))); + assertEquals(0.0, StandardInput.RADIUS.sample(at(bounds, 0, 10, 0)), 1e-12); + assertEquals(1.0, StandardInput.RADIUS.sample(at(bounds, 3, 0, 4)), 1e-12); + assertEquals(0.5, StandardInput.RADIUS.sample(at(bounds, 0, 7, 2.5)), 1e-12, "y must not affect the radius"); + } + + @Test + void spherical_isTheDistanceFromTheOrigin() { + ShapeBounds bounds = ShapeBounds.compute(List.of(new Vector3d(0, 0, 0), new Vector3d(2, 2, 1))); + assertEquals(0.0, StandardInput.SPHERICAL.sample(at(bounds, 0, 0, 0)), 1e-12); + assertEquals(1.0, StandardInput.SPHERICAL.sample(at(bounds, 2, 2, 1)), 1e-12); + assertEquals(1.0 / 3, StandardInput.SPHERICAL.sample(at(bounds, 0, 1, 0)), 1e-12); + } + + @Test + void angle_goesAroundTheYAxisStartingAtPlusX() { + ShapeBounds bounds = ShapeBounds.compute(List.of(new Vector3d(-1, 0, -1), new Vector3d(1, 0, 1))); + assertEquals(0.0, StandardInput.ANGLE.sample(at(bounds, 1, 0, 0)), 1e-12); + assertEquals(0.25, StandardInput.ANGLE.sample(at(bounds, 0, 0, 1)), 1e-12); + assertEquals(0.5, StandardInput.ANGLE.sample(at(bounds, -1, 0, 0)), 1e-12); + assertEquals(0.75, StandardInput.ANGLE.sample(at(bounds, 0, 0, -1)), 1e-12); + assertEquals(StandardInput.ANGLE.sample(at(bounds, 1, 0, 1)), StandardInput.ANGLE.sample(at(bounds, 5, 9, 5)), 1e-12, + "only the direction in the XZ plane matters"); + } + + @Test + void everyInput_staysWithinZeroToOneOnRealShapes() { + Shape sphere = new Sphere(2); + sphere.getPointSampler().setStyle(SamplingStyle.FILL); + Shape cuboid = new Cuboid(3, 1, 5); + cuboid.setOffset(new Vector3d(10, -4, 2)); + cuboid.setOrientation(new Quaterniond().rotateXYZ(0.4, 1.1, 0.2)); + Shape helix = new Helix(2, 6, 0.5); + for (Shape shape : List.of(sphere, cuboid, helix)) { + List points = shape.getPointSampler().getPoints(shape); + ShapeBounds bounds = ShapeBounds.compute(points); + for (int i = 0; i < points.size(); i++) { + Vector3d p = points.get(i); + PointContext ctx = at(bounds, p.x, p.y, p.z); + ctx.index = i; + ctx.totalPoints = points.size(); + for (StandardInput input : StandardInput.values()) { + double v = input.sample(ctx); + assertTrue(v >= -1e-9 && v <= 1 + 1e-9, + input + " on " + shape.getClass().getSimpleName() + " gave " + v + " at " + p); + } + } + } + } + + @Test + void flatBounds_doNotDivideByZero() { + ShapeBounds flat = ShapeBounds.compute(List.of(new Vector3d(-1, 2, -1), new Vector3d(1, 2, 1))); + for (StandardInput input : StandardInput.values()) { + double v = input.sample(at(flat, 0.5, 2, 0.5)); + assertTrue(Double.isFinite(v), input + " gave " + v + " for a flat shape"); + } + ShapeBounds single = ShapeBounds.compute(List.of(new Vector3d())); + for (StandardInput input : StandardInput.values()) + assertTrue(Double.isFinite(input.sample(at(single, 0, 0, 0))), input + " for a single point at the origin"); + } + + @Test + void inputHash_isDistinctPerInput() { + Set hashes = new HashSet<>(); + for (StandardInput input : StandardInput.values()) + hashes.add(input.inputHash()); + assertEquals(StandardInput.values().length, hashes.size()); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/TwistModifierTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/TwistModifierTest.java new file mode 100644 index 0000000..20f8a7c --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/TwistModifierTest.java @@ -0,0 +1,137 @@ +package com.sovdee.shapes.modifiers; + +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class TwistModifierTest { + + private PointContext ctx(double x, double y, double z, List allPoints) { + ShapeBounds bounds = ShapeBounds.compute(allPoints); + PointContext c = new PointContext(); + c.bounds = bounds; + c.x = x; c.y = y; c.z = z; + return c; + } + + private List ySpan() { + return List.of(new Vector3d(0, 0, 0), new Vector3d(0, 1, 0)); + } + + private List xSpan() { + return List.of(new Vector3d(0, 0, 0), new Vector3d(1, 0, 0)); + } + + // --- Default Y-input, XZ-plane tests --- + + @Test + void noTwistAtBottom() { + TwistModifier mod = new TwistModifier(Math.PI); + PointContext c = ctx(1, 0, 0, ySpan()); + mod.modify(c); + // angle = PI * normalizedY(0) = PI * 0 = 0 → identity rotation + assertEquals(1.0, c.x, 1e-9); + assertEquals(0.0, c.z, 1e-9); + } + + @Test + void fullTwistAtTop() { + // totalAngle = 2π, normalizedY=1 → full 360° rotation → identity + TwistModifier mod = new TwistModifier(2 * Math.PI); + PointContext c = ctx(1, 1, 0, ySpan()); + mod.modify(c); + assertEquals(1.0, c.x, 1e-9, "Full 2π twist should return to original X"); + assertEquals(0.0, c.z, 1e-9, "Full 2π twist should return to original Z"); + } + + @Test + void quarterTwistAtMidpoint() { + // totalAngle=π, normalizedY=0.5 → angle=π/2 (90°) + // newX = x*cos - z*sin = 1*cos(π/2) - 0*sin(π/2) = 0 + // newZ = x*sin + z*cos = 1*sin(π/2) + 0*cos(π/2) = 1 + TwistModifier mod = new TwistModifier(Math.PI); + PointContext c = ctx(1, 0.5, 0, ySpan()); + mod.modify(c); + assertEquals(0.0, c.x, 1e-9); + assertEquals(1.0, c.z, 1e-9); + } + + @Test + void yCoordinateUnchanged() { + TwistModifier mod = new TwistModifier(Math.PI); + PointContext c = ctx(1, 0.5, 0, ySpan()); + double origY = c.y; + mod.modify(c); + assertEquals(origY, c.y, 1e-9, "XZ-plane twist must not change Y"); + } + + // --- X-input, YZ-plane twist: rotates YZ plane, X unchanged --- + + @Test + void xInput_yzPlane_quarterTwist() { + // totalAngle=π, normalizedX=0.5 → angle=π/2 + // point (0, 1, 0): newY = 1*cos(π/2) - 0*sin(π/2) = 0, newZ = 1*sin(π/2) + 0*cos(π/2) = 1 + TwistModifier mod = new TwistModifier(Math.PI, StandardInput.X, RotationPlane.YZ); + PointContext c = ctx(0.5, 1, 0, xSpan()); + mod.modify(c); + assertEquals(0.0, c.y, 1e-9, "Y rotated to 0"); + assertEquals(1.0, c.z, 1e-9, "Z rotated to 1"); + assertEquals(0.5, c.x, 1e-9, "X must not change in YZ-plane twist"); + } + + @Test + void xInput_xCoordinateUnchanged() { + TwistModifier mod = new TwistModifier(Math.PI, StandardInput.X, RotationPlane.YZ); + PointContext c = ctx(0.5, 1, 0, xSpan()); + double origX = c.x; + mod.modify(c); + assertEquals(origX, c.x, 1e-9, "YZ-plane twist must not change X"); + } + + // --- XY-plane twist --- + + @Test + void xyPlane_zCoordinateUnchanged() { + TwistModifier mod = new TwistModifier(Math.PI, StandardInput.Y, RotationPlane.XY); + PointContext c = ctx(1, 0.5, 7, ySpan()); + double origZ = c.z; + mod.modify(c); + assertEquals(origZ, c.z, 1e-9, "XY-plane twist must not change Z"); + } + + // --- Hash and clone --- + + @Test + void modifierHashChangesWithAngle() { + TwistModifier a = new TwistModifier(Math.PI); + TwistModifier b = new TwistModifier(Math.PI / 2); + assertNotEquals(a.modifierHash(), b.modifierHash()); + } + + @Test + void modifierHashChangesWithInput() { + TwistModifier y = new TwistModifier(Math.PI, StandardInput.Y, RotationPlane.XZ); + TwistModifier x = new TwistModifier(Math.PI, StandardInput.X, RotationPlane.XZ); + TwistModifier t = new TwistModifier(Math.PI, StandardInput.T, RotationPlane.XZ); + assertNotEquals(y.modifierHash(), x.modifierHash(), "Y vs X input"); + assertNotEquals(y.modifierHash(), t.modifierHash(), "Y vs T input"); + } + + @Test + void modifierHashChangesWithPlane() { + TwistModifier xz = new TwistModifier(Math.PI, StandardInput.Y, RotationPlane.XZ); + TwistModifier xy = new TwistModifier(Math.PI, StandardInput.Y, RotationPlane.XY); + assertNotEquals(xz.modifierHash(), xy.modifierHash(), "XZ vs XY plane"); + } + + @Test + void cloneIsIndependent() { + TwistModifier original = new TwistModifier(Math.PI); + TwistModifier copy = original.clone(); + copy.setTotalAngle(99.0); + assertEquals(Math.PI, original.getTotalAngle(), "Mutating clone must not affect original"); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/WaveModifierTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/WaveModifierTest.java new file mode 100644 index 0000000..be43666 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/modifiers/WaveModifierTest.java @@ -0,0 +1,138 @@ +package com.sovdee.shapes.modifiers; + +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class WaveModifierTest { + + private PointContext ctxWithIndex(double x, double y, double z, int index, int total) { + ShapeBounds bounds = ShapeBounds.compute(List.of(new Vector3d(x, y, z))); + PointContext c = new PointContext(); + c.bounds = bounds; + c.x = x; c.y = y; c.z = z; + c.index = index; + c.totalPoints = total; + return c; + } + + private PointContext ctxT(int index, int total) { + return ctxWithIndex(0, 0, 0, index, total); + } + + // frequency=1 means one full sine cycle over T=[0,1]. + // At T=0.25 with freq=1: sin(2π * 1 * 0.25) = sin(π/2) = 1 + + @Test + void displaceY_byT_quarterCycle() { + // T=0.25, freq=1 → sin(2π * 0.25) = sin(π/2) = 1 → displacement = amplitude + WaveModifier mod = new WaveModifier(1.0, 1.0, 0.0, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(5, 20); // T = 5/20 = 0.25 + mod.modify(c); + assertEquals(Math.sin(2 * Math.PI * 0.25), c.y, 1e-9); + } + + @Test + void displaceY_byT_halfCycle() { + // T=0.5, freq=1 → sin(2π * 0.5) = sin(π) ≈ 0 + WaveModifier mod = new WaveModifier(1.0, 1.0, 0.0, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(10, 20); // T = 0.5 + mod.modify(c); + assertEquals(Math.sin(2 * Math.PI * 0.5), c.y, 1e-9); + } + + @Test + void displaceY_byT_twoCycles() { + // T=0.25, freq=2 → sin(2π * 2 * 0.25) = sin(π) ≈ 0 + WaveModifier mod = new WaveModifier(1.0, 2.0, 0.0, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(5, 20); // T = 0.25 + mod.modify(c); + assertEquals(Math.sin(2 * Math.PI * 2 * 0.25), c.y, 1e-9); + } + + @Test + void displaceByT_zeroTotalPoints() { + WaveModifier mod = new WaveModifier(1.0, 1.0, 0.0, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(0, 0); // totalPoints=0, T=0 → sin(0) = 0 + assertDoesNotThrow(() -> mod.modify(c)); + assertEquals(0.0, c.y, 1e-9, "T=0 when totalPoints=0 → zero displacement"); + } + + @Test + void zeroAmplitude() { + WaveModifier mod = new WaveModifier(0.0, 3.0, 0.0, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(5, 20); + double origY = c.y; + mod.modify(c); + assertEquals(origY, c.y, 1e-9, "Zero amplitude → no displacement"); + } + + @Test + void zeroFrequency_withPhase() { + // freq=0: sin(0 * T + phase) = sin(phase); T is irrelevant + double phase = Math.PI / 6; // sin(PI/6) = 0.5 + WaveModifier mod = new WaveModifier(2.0, 0.0, phase, StandardInput.T, SpatialAxis.Y); + PointContext c = ctxT(15, 20); // T = 0.75, irrelevant since freq=0 + mod.modify(c); + assertEquals(2.0 * Math.sin(phase), c.y, 1e-9); + } + + @Test + void phaseShift_producesDistinctDisplacement() { + WaveModifier mod1 = new WaveModifier(1.0, 1.0, 0.0, StandardInput.T, SpatialAxis.Y); + WaveModifier mod2 = new WaveModifier(1.0, 1.0, Math.PI / 4, StandardInput.T, SpatialAxis.Y); + PointContext c1 = ctxT(5, 20); + PointContext c2 = ctxT(5, 20); + mod1.modify(c1); + mod2.modify(c2); + assertNotEquals(c1.y, c2.y, "Different phase → different displacement"); + } + + @ParameterizedTest + @EnumSource(SpatialAxis.class) + void allOutputAxes_nonOutputAxesUnchanged(SpatialAxis outputAxis) { + WaveModifier mod = new WaveModifier(1.0, 1.0, 0.0, StandardInput.T, outputAxis); + PointContext c = ctxT(5, 20); // T=0.25 → non-zero displacement + double origX = c.x, origY = c.y, origZ = c.z; + mod.modify(c); + if (outputAxis != SpatialAxis.X) assertEquals(origX, c.x, 1e-9, "X unchanged when output != X"); + if (outputAxis != SpatialAxis.Y) assertEquals(origY, c.y, 1e-9, "Y unchanged when output != Y"); + if (outputAxis != SpatialAxis.Z) assertEquals(origZ, c.z, 1e-9, "Z unchanged when output != Z"); + } + + @Test + void modifierHashCoversAllParams() { + WaveModifier base = new WaveModifier(1.0, 2.0, 0.5, StandardInput.X, SpatialAxis.Y); + assertNotEquals(base.modifierHash(), + new WaveModifier(9.0, 2.0, 0.5, StandardInput.X, SpatialAxis.Y).modifierHash(), + "Different amplitude"); + assertNotEquals(base.modifierHash(), + new WaveModifier(1.0, 9.0, 0.5, StandardInput.X, SpatialAxis.Y).modifierHash(), + "Different frequency"); + assertNotEquals(base.modifierHash(), + new WaveModifier(1.0, 2.0, 9.0, StandardInput.X, SpatialAxis.Y).modifierHash(), + "Different phase"); + assertNotEquals(base.modifierHash(), + new WaveModifier(1.0, 2.0, 0.5, StandardInput.Z, SpatialAxis.Y).modifierHash(), + "Different input"); + assertNotEquals(base.modifierHash(), + new WaveModifier(1.0, 2.0, 0.5, StandardInput.X, SpatialAxis.Z).modifierHash(), + "Different output axis"); + assertNotEquals(base.modifierHash(), + new WaveModifier(1.0, 2.0, 0.5, StandardInput.T, SpatialAxis.Y).modifierHash(), + "T input differs from X"); + } + + @Test + void cloneIsIndependent() { + WaveModifier original = new WaveModifier(1.0, 2.0, 0.0, StandardInput.X, SpatialAxis.Y); + WaveModifier copy = original.clone(); + copy.setAmplitude(99.0); + assertEquals(1.0, original.getAmplitude(), "Mutating clone must not affect original"); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/sampling/DefaultPointSamplerTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/DefaultPointSamplerTest.java new file mode 100644 index 0000000..a25f6c7 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/DefaultPointSamplerTest.java @@ -0,0 +1,416 @@ +package com.sovdee.shapes.sampling; + +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.modifiers.ScalingModifier; +import com.sovdee.shapes.modifiers.ScaleAxes; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.shapes.Circle; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class DefaultPointSamplerTest { + + // ----- Minimal ShapeRenderer helpers ----- + + static class CountingRenderer implements ShapeRenderer { + int beginCount, endCount, renderCount; + int lastBeginTotal; + final PointContext ctx = new PointContext(); + + @Override public void begin(int total) { beginCount++; lastBeginTotal = total; } + @Override public void renderPoint(PointContext p) { renderCount++; } + @Override public PointContext getContext() { return ctx; } + @Override public void end() { endCount++; } + } + + /** A render modifier (not PreRender) that counts prepare() and modify() calls. */ + static class CountingRenderModifier implements PointModifier { + int prepareCount, modifyCount; + + @Override public void prepare(ShapeBounds b) { prepareCount++; } + @Override public void modify(PointContext p) { modifyCount++; } + @Override public int modifierHash() { return 42; } + @Override public PointModifier clone() { + try { return (CountingRenderModifier) super.clone(); } + catch (CloneNotSupportedException e) { throw new RuntimeException(e); } + } + } + + /** A geometry modifier (PreRender) that counts calls. */ + static class CountingGeoModifier extends PointModifier.PreRenderPointModifier { + int modifyCount; + @Override public void modify(PointContext p) { modifyCount++; } + @Override public int modifierHash() { return 7; } + } + + // A PointContext subclass for context-type validation tests + static class SubContext extends PointContext { + int extra; + @Override public void reset() { extra = 0; } + } + + static class SubContextModifier implements PointModifier { + @Override public void modify(SubContext p) { p.extra = 99; } + @Override public int modifierHash() { return 1; } + @Override public Class contextType() { return SubContext.class; } + @Override public PointModifier clone() { + try { return (SubContextModifier) super.clone(); } + catch (CloneNotSupportedException e) { throw new RuntimeException(e); } + } + } + + static class SubContextRenderer implements ShapeRenderer { + final SubContext ctx = new SubContext(); + int renderCount; + @Override public void begin(int total) {} + @Override public void renderPoint(SubContext p) { renderCount++; } + @Override public SubContext getContext() { return ctx; } + @Override public void end() {} + } + + // ----- Test setup ----- + + Circle circle; + DefaultPointSampler sampler; + + @BeforeEach + void setUp() { + circle = new Circle(1.0); + sampler = new DefaultPointSampler(); + sampler.setDensity(0.5); // coarse for speed + } + + // ----- Caching tests ----- + + @Test + void cacheHit_sameState() { + List first = sampler.getPoints(circle); + List second = sampler.getPoints(circle); + assertSame(first, second, "Same state should return cached list"); + } + + @Test + void cacheMiss_onDensityChange() { + List first = sampler.getPoints(circle); + sampler.setDensity(0.2); + List second = sampler.getPoints(circle); + assertNotSame(first, second, "Density change should invalidate cache"); + } + + @Test + void cacheMiss_onStyleChange() { + List first = sampler.getPoints(circle); + sampler.setStyle(SamplingStyle.SURFACE); + List second = sampler.getPoints(circle); + assertNotSame(first, second, "Style change should invalidate cache"); + } + + @Test + void cacheMiss_onShapeInvalidate() { + List first = sampler.getPoints(circle); + circle.setRadius(2.0); // bumps version + List second = sampler.getPoints(circle); + assertNotSame(first, second, "Shape geometry change should invalidate cache"); + } + + @Test + void cacheHit_renderModifierAdded() { + List first = sampler.getPoints(circle); + // Render modifiers don't affect geometry; cache should survive + sampler.addModifier(new CountingRenderModifier()); + List second = sampler.getPoints(circle); + assertSame(first, second, "Adding a render modifier must not invalidate geometry cache"); + } + + @Test + void cacheMiss_geoModifierAdded() { + List first = sampler.getPoints(circle); + sampler.addModifier(new CountingGeoModifier()); // PreRenderPointModifier → calls markDirty + List second = sampler.getPoints(circle); + assertNotSame(first, second, "Adding a geo modifier must invalidate cache"); + } + + // ----- Geometry modifier dispatch ----- + + @Test + void geoModifier_appliedToPoints() { + // Scale from 0 to 0 collapses all XZ to zero + sampler.addModifier(new ScalingModifier(0.0, 0.0, StandardInput.Y, ScaleAxes.XZ)); + List points = sampler.getPoints(circle); + for (Vector3d p : points) { + assertEquals(0.0, p.x, 1e-9, "ScalingModifier(0,0) should zero all X"); + assertEquals(0.0, p.z, 1e-9, "ScalingModifier(0,0) should zero all Z"); + } + } + + @Test + void geoModifier_notCalledDuringRender() { + CountingGeoModifier geoMod = new CountingGeoModifier(); + sampler.addModifier(geoMod); + + int pointCount = sampler.getPoints(circle).size(); + int afterGetPoints = geoMod.modifyCount; + assertEquals(pointCount, afterGetPoints, "Geo modifier should be called once per point in getPoints()"); + + // render() should NOT re-invoke the geo modifier + CountingRenderer renderer = new CountingRenderer(); + sampler.render(circle, circle.getOrientation(), renderer); + assertEquals(afterGetPoints, geoMod.modifyCount, "Geo modifier must not run again during render()"); + } + + // ----- Render loop ----- + + @Test + void render_callsBeginAndEnd() { + CountingRenderer renderer = new CountingRenderer(); + sampler.render(circle, circle.getOrientation(), renderer); + assertEquals(1, renderer.beginCount); + assertEquals(1, renderer.endCount); + } + + @Test + void render_callsRenderPointForEachPoint() { + CountingRenderer renderer = new CountingRenderer(); + sampler.render(circle, circle.getOrientation(), renderer); + int pointCount = sampler.getPoints(circle).size(); + assertEquals(pointCount, renderer.renderCount, + "renderPoint() should be called exactly once per point"); + } + + @Test + void render_preparesModifier() { + CountingRenderModifier renderMod = new CountingRenderModifier(); + sampler.addModifier(renderMod); + CountingRenderer renderer = new CountingRenderer(); + + sampler.render(circle, circle.getOrientation(), renderer); + assertEquals(1, renderMod.prepareCount, "Render modifier prepare() must be called once per render()"); + + sampler.render(circle, circle.getOrientation(), renderer); + assertEquals(2, renderMod.prepareCount, "Prepare must be called once per render() invocation"); + } + + // ----- Context type validation ----- + + @Test + void render_rejectsIncompatibleModifier() { + // SubContextModifier requires SubContext, but renderer provides plain PointContext + sampler.addModifier(new SubContextModifier()); + CountingRenderer renderer = new CountingRenderer(); // provides PointContext + assertThrows(IllegalStateException.class, + () -> sampler.render(circle, circle.getOrientation(), renderer), + "Should throw when modifier context type is not assignable from renderer context type"); + } + + @Test + void render_acceptsCompatibleModifier() { + sampler.addModifier(new CountingRenderModifier()); // contextType=PointContext + CountingRenderer renderer = new CountingRenderer(); // provides PointContext + assertDoesNotThrow(() -> sampler.render(circle, circle.getOrientation(), renderer)); + } + + @Test + void render_acceptsSubtypeContext() { + // Modifier needs PointContext; renderer provides SubContext (which IS a PointContext) + sampler.addModifier(new CountingRenderModifier()); // contextType=PointContext + SubContextRenderer renderer = new SubContextRenderer(); // provides SubContext + assertDoesNotThrow(() -> sampler.render(circle, circle.getOrientation(), renderer)); + } + + // ----- Batched render pass ----- + + /** Records the context index of every rendered point. */ + static class IndexRecordingRenderer implements ShapeRenderer { + int beginCount, endCount; + final List indices = new ArrayList<>(); + final List totals = new ArrayList<>(); + final PointContext ctx = new PointContext(); + + @Override public void begin(int total) { beginCount++; } + @Override public void renderPoint(PointContext p) { indices.add(p.index); totals.add(p.totalPoints); } + @Override public PointContext getContext() { return ctx; } + @Override public void end() { endCount++; } + } + + @Test + void preparePass_batchedIndicesAreContinuous() { + CountingRenderModifier renderMod = new CountingRenderModifier(); + sampler.addModifier(renderMod); + IndexRecordingRenderer renderer = new IndexRecordingRenderer(); + + List points = sampler.getPoints(circle); + RenderPass pass = sampler.preparePass(circle, points, renderer.getContext()); + assertEquals(1, renderMod.prepareCount, "prepare() must run once per pass, not per batch"); + + int mid = points.size() / 2; + pass.renderBatch(points.subList(0, mid), renderer); + pass.renderBatch(points.subList(mid, points.size()), renderer); + + assertEquals(points.size(), renderer.indices.size()); + for (int i = 0; i < points.size(); i++) { + assertEquals(i, renderer.indices.get(i), "Point index must stay continuous across batches"); + assertEquals(points.size(), renderer.totals.get(i), "totalPoints must reflect the full pass, not the batch"); + } + assertEquals(points.size(), renderMod.modifyCount, "Render modifier must run once per point across all batches"); + assertEquals(2, renderer.beginCount, "begin() should run once per batch"); + assertEquals(2, renderer.endCount, "end() should run once per batch"); + assertEquals(1, renderMod.prepareCount, "prepare() must not run again for later batches"); + } + + @Test + void preparePass_rejectsIncompatibleModifier() { + sampler.addModifier(new SubContextModifier()); + List points = sampler.getPoints(circle); + assertThrows(IllegalStateException.class, + () -> sampler.preparePass(circle, points, new PointContext()), + "preparePass must validate modifier context types like render()"); + } + + // ----- Transform pipeline ----- + + @Test + void getPoints_appliesScale() { + circle.setScale(2.0); + List points = sampler.getPoints(circle, new Quaterniond()); + for (Vector3d p : points) { + double dist = Math.sqrt(p.x * p.x + p.z * p.z); + assertEquals(2.0, dist, 1e-6, "Scale=2 on radius=1 circle → points at distance 2"); + } + } + + @Test + void getPoints_appliesOffset() { + double offsetX = 5.0; + circle.setOffset(new Vector3d(offsetX, 0, 0)); + + // Get points without offset for comparison + Circle baseCircle = new Circle(1.0); + DefaultPointSampler baseSampler = new DefaultPointSampler(); + baseSampler.setDensity(0.5); + List base = new ArrayList<>(baseSampler.getPoints(baseCircle, new Quaterniond())); + + List offset = sampler.getPoints(circle, new Quaterniond()); + assertEquals(base.size(), offset.size()); + for (int i = 0; i < base.size(); i++) { + assertEquals(base.get(i).x + offsetX, offset.get(i).x, 1e-6, + "Offset X should shift all points"); + } + } + + // ----- Auto-density (max-points) ----- + + @Test + void autoDensity_capsPointCount() { + // Fresh sampler — no explicit density. Large circle should be capped. + Circle large = new Circle(10.0); + DefaultPointSampler fresh = new DefaultPointSampler(); + // Default cap is 10 000; default density (0.25) on radius-10 circle = ~628 points — well under cap. + // Use a tiny cap so the test doesn't depend on default density math. + fresh.setMaxPoints(100); + List points = fresh.getPoints(large); + // Allow a 5% overshoot: computeDensity is an approximation and loop arithmetic + // can produce 1-2 extra points due to floating-point step accumulation. + assertTrue(points.size() <= 105, + "Auto-density should cap point count near maxPoints (≤105), got " + points.size()); + } + + @Test + void autoDensity_defaultCapIsApplied() { + // A very large circle with default sampler (no explicit density, default cap = 10 000). + Circle huge = new Circle(1000.0); + DefaultPointSampler fresh = new DefaultPointSampler(); + assertFalse(fresh.isDensityExplicit()); + List points = fresh.getPoints(huge); + assertTrue(points.size() <= 10_500, + "Default 10 000 cap should be respected (≤10 500 allowing FP overshoot), got " + points.size()); + } + + @Test + void explicitDensity_isNotLimitedByTheDefaultLimit() { + // radius 100 at density 0.05 is ~12 500 points: over the default limit, under the explicit-density one + Circle large = new Circle(100.0); + DefaultPointSampler s = new DefaultPointSampler(); + s.setDensity(0.05); + assertEquals(DefaultPointSampler.HARD_MAX_POINTS, s.getMaxPoints()); + assertTrue(s.getPoints(large).size() > DefaultPointSampler.DEFAULT_MAX_POINTS, + "an explicit density should only be limited by the hard limit until a limit is set"); + } + + @Test + void setLimit_appliesToAnExplicitDensityToo() { + Circle large = new Circle(10.0); + DefaultPointSampler s = new DefaultPointSampler(); + s.setDensity(0.5); // ~125 points + s.setMaxPoints(50); + assertTrue(s.isDensityExplicit(), "setting the limit must not change the density"); + assertEquals(0.5, s.getDensity(), 1e-12); + assertTrue(s.getPoints(large).size() <= 50, "the limit should apply to an explicit density"); + } + + @Test + void setLimit_appliesToTheDefaultDensity() { + Circle large = new Circle(10.0); + DefaultPointSampler s = new DefaultPointSampler(); + s.setMaxPoints(50); + assertFalse(s.isDensityExplicit()); + assertEquals(50, s.getMaxPoints()); + assertTrue(s.getPoints(large).size() <= 50); + } + + @Test + void defaultLimit_dependsOnTheDensityUntilALimitIsSet() { + DefaultPointSampler s = new DefaultPointSampler(); + assertEquals(DefaultPointSampler.DEFAULT_MAX_POINTS, s.getMaxPoints()); + s.setDensity(0.1); + assertEquals(DefaultPointSampler.HARD_MAX_POINTS, s.getMaxPoints()); + s.setMaxPoints(2_000); + assertEquals(2_000, s.getMaxPoints()); + s.resetDensity(); + assertEquals(2_000, s.getMaxPoints(), "a set limit outlives a density reset"); + s.resetMaxPoints(); + assertEquals(DefaultPointSampler.DEFAULT_MAX_POINTS, s.getMaxPoints()); + } + + @Test + void changingTheLimit_resamples() { + Circle large = new Circle(10.0); + DefaultPointSampler s = new DefaultPointSampler(); + int before = s.getPoints(large).size(); + s.setMaxPoints(20); + assertTrue(s.getPoints(large).size() <= 20); + s.resetMaxPoints(); + assertEquals(before, s.getPoints(large).size()); + } + + @Test + void setMaxPoints_clampedToOne() { + DefaultPointSampler s = new DefaultPointSampler(); + s.setMaxPoints(0); + assertEquals(1, s.getMaxPoints(), "setMaxPoints(0) should clamp to 1"); + s.setMaxPoints(-5); + assertEquals(1, s.getMaxPoints(), "setMaxPoints(-5) should clamp to 1"); + } + + @Test + void autoDensity_cacheInvalidatesOnShapeChange() { + Circle large = new Circle(10.0); + DefaultPointSampler s = new DefaultPointSampler(); + s.setMaxPoints(200); + + List first = s.getPoints(large); + large.setRadius(20.0); // geometry change + List second = s.getPoints(large); + assertNotSame(first, second, "Cache must invalidate when shape geometry changes in auto-density mode"); + assertTrue(second.size() <= 210, + "After geometry change auto-density should still cap near maxPoints, got " + second.size()); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointCapAndCacheTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointCapAndCacheTest.java new file mode 100644 index 0000000..bf1a067 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointCapAndCacheTest.java @@ -0,0 +1,297 @@ +package com.sovdee.shapes.sampling; + +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ScaleAxes; +import com.sovdee.shapes.modifiers.ScalingModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.shapes.Circle; +import com.sovdee.shapes.shapes.Cuboid; +import com.sovdee.shapes.shapes.Rectangle; +import com.sovdee.shapes.shapes.Shape; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the point cap, particle count targeting, and how the cache behaves across clones. + */ +class PointCapAndCacheTest { + + /** A cuboid whose density estimate is a fixed value, regardless of the requested point count. */ + static class FixedEstimateCuboid extends Cuboid { + private final double estimate; + + FixedEstimateCuboid(double size, double estimate) { + super(size, size, size); + this.estimate = estimate; + } + + @Override + public double computeDensity(SamplingStyle style, int targetPointCount) { + return estimate; + } + } + + /** A rectangle whose density estimate is always zero, as if it had no measurable size. */ + static class ZeroEstimateRectangle extends Rectangle { + ZeroEstimateRectangle(double length, double width) { + super(length, width, Plane.XZ); + } + + @Override + public double computeDensity(SamplingStyle style, int targetPointCount) { + return 0; + } + } + + /** A geometry modifier that records the bounds it was prepared with. */ + static class BoundsRecorder extends PointModifier.PreRenderPointModifier { + double maxX = Double.NaN; + + @Override public void prepare(ShapeBounds bounds) { maxX = bounds.maxX(); } + @Override public void modify(PointContext point) {} + @Override public int modifierHash() { return 11; } + } + + // ----- Cap ----- + + @Test + void defaultDensity_isUsedWhenUnderTheCap() { + Circle small = new Circle(1.0); + int count = small.getPointSampler().getPoints(small).size(); + // circumference 2π at the default 0.25 spacing is about 25 points; the cap of 10 000 must not be a target + assertTrue(count >= 20 && count <= 30, "expected about 25 points, got " + count); + } + + @Test + void cap_holdsWhenTheEstimateIgnoresTheTarget() { + // 40^3 at 0.25 spacing would be over 4 million points + Shape shape = new FixedEstimateCuboid(40, 0.25); + shape.getPointSampler().setStyle(SamplingStyle.FILL); + int count = shape.getPointSampler().getPoints(shape).size(); + assertTrue(count <= 10_000, "cap exceeded: " + count); + assertTrue(count >= 2_000, "cap should be approached, not wildly undershot: " + count); + } + + @Test + void cap_holdsWhenTheEstimateIsFarTooFine() { + Shape shape = new FixedEstimateCuboid(40, 0.0001); + shape.getPointSampler().setStyle(SamplingStyle.SURFACE); + shape.getPointSampler().setMaxPoints(500); + int count = shape.getPointSampler().getPoints(shape).size(); + assertTrue(count <= 500, "cap exceeded: " + count); + assertTrue(count >= 100, "cap should be approached, not wildly undershot: " + count); + } + + @Test + void effectiveDensity_reportsTheDensityInUse() { + Circle small = new Circle(1.0); + assertEquals(DefaultPointSampler.DEFAULT_DENSITY, small.getPointSampler().getEffectiveDensity(small), 1e-12); + + Circle huge = new Circle(1000.0); + assertTrue(huge.getPointSampler().getEffectiveDensity(huge) > DefaultPointSampler.DEFAULT_DENSITY, + "a capped shape should report the coarser density it was sampled at"); + assertEquals(DefaultPointSampler.DEFAULT_DENSITY, huge.getPointSampler().getDensity(), 1e-12, + "the base density itself should be unchanged"); + } + + @Test + void resetDensity_restoresTheDefault() { + DefaultPointSampler sampler = new DefaultPointSampler(); + sampler.setDensity(0.01); + sampler.resetDensity(); + assertFalse(sampler.isDensityExplicit()); + assertEquals(DefaultPointSampler.DEFAULT_DENSITY, sampler.getDensity(), 1e-12); + } + + // ----- Hard limit ----- + + @Test + void explicitDensity_isCappedAtTheHardLimit() { + // a filled 12^3 cube at 1/12 spacing would be about 2.5 million points + Cuboid cube = new Cuboid(12, 12, 12); + DefaultPointSampler sampler = (DefaultPointSampler) cube.getPointSampler(); + sampler.setStyle(SamplingStyle.FILL); + sampler.setDensity(1 / 12.0); + int count = sampler.getPoints(cube).size(); + assertTrue(count <= DefaultPointSampler.HARD_MAX_POINTS, "hard limit exceeded: " + count); + assertTrue(count >= DefaultPointSampler.HARD_MAX_POINTS / 4, "hard limit should be approached, not wildly undershot: " + count); + assertTrue(sampler.isDensityExplicit(), "hitting the hard limit should not drop the explicit density"); + assertEquals(1 / 12.0, sampler.getDensity(), 1e-12, "the requested density itself should be unchanged"); + assertTrue(sampler.getEffectiveDensity(cube) > 1 / 12.0, "the density in use should be coarser than requested"); + } + + @Test + void explicitDensity_isExactUnderTheHardLimit() { + // a misleading estimate must not coarsen an explicit density that fits + Shape shape = new FixedEstimateCuboid(10, 5); + DefaultPointSampler sampler = (DefaultPointSampler) shape.getPointSampler(); + sampler.setStyle(SamplingStyle.FILL); + sampler.setDensity(0.5); // 21^3 = 9 261 points + assertEquals(0.5, sampler.getEffectiveDensity(shape), 1e-12); + assertTrue(sampler.getPoints(shape).size() > 5_000, "explicit density should be used exactly"); + } + + @Test + void explicitDensity_isCappedWhenTheEstimateIsWrong() { + Shape shape = new FixedEstimateCuboid(40, 0.0001); + shape.getPointSampler().setStyle(SamplingStyle.FILL); + shape.getPointSampler().setDensity(0.1); // 401^3 is about 64 million points + int count = shape.getPointSampler().getPoints(shape).size(); + assertTrue(count <= DefaultPointSampler.HARD_MAX_POINTS, "hard limit exceeded: " + count); + } + + @Test + void setParticleCount_isCappedAtTheHardLimit() { + Cuboid cube = new Cuboid(12, 12, 12); + cube.getPointSampler().setStyle(SamplingStyle.FILL); + cube.getPointSampler().setParticleCount(cube, 1_000_000); + int count = cube.getPointSampler().getPointCount(cube); + assertTrue(count <= DefaultPointSampler.HARD_MAX_POINTS, "hard limit exceeded: " + count); + } + + @Test + void setMaxPoints_isClampedToTheHardLimit() { + DefaultPointSampler sampler = new DefaultPointSampler(); + sampler.setMaxPoints(5_000_000); + assertEquals(DefaultPointSampler.HARD_MAX_POINTS, sampler.getMaxPoints()); + } + + // ----- UUID ----- + + @Test + void uuid_isStableAndNotSharedWithClones() { + DefaultPointSampler sampler = new DefaultPointSampler(); + UUID id = sampler.getUUID(); + assertNotNull(id); + assertSame(id, sampler.getUUID(), "a sampler's UUID must not change once generated"); + + DefaultPointSampler copy = sampler.clone(); + assertNotEquals(id, copy.getUUID(), "a clone must get its own UUID"); + assertSame(copy.getUUID(), copy.getUUID()); + assertSame(id, sampler.getUUID(), "cloning must not change the original's UUID"); + } + + @Test + void uuid_isUniqueWhenClonedBeforeFirstUse() { + DefaultPointSampler sampler = new DefaultPointSampler(); + DefaultPointSampler copy = sampler.clone(); + assertNotEquals(sampler.getUUID(), copy.getUUID()); + } + + // ----- Bounds ----- + + @Test + void boundsOf_reusesTheBoundsOfCachedPoints() { + Circle circle = new Circle(2.0); + PointSampler sampler = circle.getPointSampler(); + List points = sampler.getPoints(circle); + ShapeBounds bounds = sampler.boundsOf(points); + assertSame(bounds, sampler.boundsOf(points), "bounds of the cached points should be computed once"); + assertSame(bounds, sampler.boundsOf(sampler.getPoints(circle)), "a cache hit should reuse the bounds"); + assertEquals(2.0, bounds.maxDistance(), 1e-9); + } + + @Test + void boundsOf_computesOtherPointLists() { + Circle circle = new Circle(2.0); + PointSampler sampler = circle.getPointSampler(); + sampler.getPoints(circle); + assertEquals(5.0, sampler.boundsOf(List.of(new Vector3d(5, 0, 0))).maxDistance(), 1e-12, + "a list that isn't the cached one must not be given the cached bounds"); + } + + @Test + void boundsOf_followsTheCacheWhenItIsInvalidated() { + Circle circle = new Circle(2.0); + PointSampler sampler = circle.getPointSampler(); + ShapeBounds before = sampler.boundsOf(sampler.getPoints(circle)); + circle.setRadius(4.0); + ShapeBounds after = sampler.boundsOf(sampler.getPoints(circle)); + assertNotSame(before, after); + assertEquals(4.0, after.maxDistance(), 1e-9); + } + + // ----- Particle count ----- + + @Test + void setParticleCount_correctsABadEstimate() { + Shape shape = new FixedEstimateCuboid(10, 0.25); // the estimate alone would give 68 921 points + shape.getPointSampler().setStyle(SamplingStyle.FILL); + shape.getPointSampler().setParticleCount(shape, 1000); + int count = shape.getPointSampler().getPointCount(shape); + // a filled cube can only produce cubes of whole numbers: 729, 1000, 1331... + assertTrue(count >= 729 && count <= 1331, "expected close to 1000 points, got " + count); + } + + @Test + void setParticleCount_survivesAZeroEstimate() { + Shape shape = new ZeroEstimateRectangle(10, 10); + shape.getPointSampler().setStyle(SamplingStyle.SURFACE); + shape.getPointSampler().setParticleCount(shape, 500); + assertTrue(shape.getPointSampler().getDensity() > 0.01, + "a zero estimate must not become a near-zero density: " + shape.getPointSampler().getDensity()); + int count = shape.getPointSampler().getPointCount(shape); + assertTrue(count >= 400 && count <= 600, "expected close to 500 points, got " + count); + } + + // ----- Cache and clones ----- + + @Test + void points_areUnmodifiable() { + Circle circle = new Circle(2.0); + List points = circle.getPointSampler().getPoints(circle); + assertThrows(UnsupportedOperationException.class, () -> points.add(new Vector3d())); + assertThrows(UnsupportedOperationException.class, points::clear); + } + + @Test + void clone_startsWithTheCachedPoints() { + Circle circle = new Circle(2.0); + List points = circle.getPointSampler().getPoints(circle); + Shape copy = circle.clone(); + assertSame(points, copy.getPointSampler().getPoints(copy), "a clone should not need to resample"); + } + + @Test + void clone_hasItsOwnIdentity() { + Circle circle = new Circle(2.0); + Shape copy = circle.clone(); + assertNotEquals(circle.getPointSampler().getUUID(), copy.getPointSampler().getUUID()); + } + + @Test + void clone_andOriginal_invalidateIndependently() { + Circle circle = new Circle(2.0); + List points = circle.getPointSampler().getPoints(circle); + Circle copy = (Circle) circle.clone(); + + copy.setRadius(5.0); + List copyPoints = copy.getPointSampler().getPoints(copy); + assertNotSame(points, copyPoints, "changing the clone must resample the clone"); + assertSame(points, circle.getPointSampler().getPoints(circle), "changing the clone must not affect the original"); + + // changing the original must not make it reuse points sampled for the clone, or vice versa + circle.setRadius(1.0); + assertNotSame(points, circle.getPointSampler().getPoints(circle)); + assertSame(copyPoints, copy.getPointSampler().getPoints(copy)); + } + + // ----- Geometry modifiers ----- + + @Test + void geometryModifiers_seeTheBoundsLeftByEarlierModifiers() { + Circle circle = new Circle(1.0); + BoundsRecorder recorder = new BoundsRecorder(); + circle.getPointSampler().addModifier(new ScalingModifier(2.0, 2.0, StandardInput.Y, ScaleAxes.X)); + circle.getPointSampler().addModifier(recorder); + circle.getPointSampler().getPoints(circle); + assertEquals(2.0, recorder.maxX, 1e-9, "the second modifier should see the scaled shape"); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointOrderTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointOrderTest.java new file mode 100644 index 0000000..7f1594a --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/sampling/PointOrderTest.java @@ -0,0 +1,106 @@ +package com.sovdee.shapes.sampling; + +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.shapes.Circle; +import com.sovdee.shapes.shapes.Helix; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.shapes.shapes.Sphere; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PointOrderTest { + + private static void assertOrdered(List points, Comparator order, String message) { + for (int i = 1; i < points.size(); i++) + assertTrue(order.compare(points.get(i - 1), points.get(i)) <= 0, message + " at " + i); + } + + /** The points before rotation, scale and offset, in the order the sampler returned them. */ + private static List local(Shape shape) { + Quaterniond inverse = new Quaterniond(shape.getOrientation()).invert(); + List out = new ArrayList<>(); + for (Vector3d p : shape.getPointSampler().getPoints(shape)) { + Vector3d v = new Vector3d(p).sub(shape.getOffset()).div(shape.getScale()); + out.add(inverse.transform(v)); + } + return out; + } + + @Test + void along_sortsByEveryOrderableInput() { + Shape sphere = new Sphere(2); + sphere.getPointSampler().setStyle(SamplingStyle.FILL); + for (StandardInput input : StandardInput.values()) { + if (input == StandardInput.T) + continue; + Comparator order = PointOrder.along(input); + sphere.getPointSampler().setOrdering(order); + assertOrdered(sphere.getPointSampler().getPoints(sphere), order, input.toString()); + } + } + + @Test + void along_drawOrderIsRejected() { + assertThrows(IllegalArgumentException.class, () -> PointOrder.along(StandardInput.T)); + } + + @Test + void reversedOrder_isTheExactReverse() { + Shape helix = new Helix(1, 4, 1); + helix.getPointSampler().setOrdering(PointOrder.along(StandardInput.Y)); + List up = new ArrayList<>(helix.getPointSampler().getPoints(helix)); + helix.getPointSampler().setOrdering(PointOrder.along(StandardInput.Y).reversed()); + List down = helix.getPointSampler().getPoints(helix); + assertEquals(up.getFirst().y, down.getLast().y, 1e-9); + assertEquals(up.getLast().y, down.getFirst().y, 1e-9); + } + + @Test + void angle_startsAtPlusXAndTurnsTowardPlusZ() { + Comparator angle = PointOrder.along(StandardInput.ANGLE); + List points = new ArrayList<>(List.of( + new Vector3d(0, 0, -1), new Vector3d(-1, 0, 0), new Vector3d(0, 0, 1), new Vector3d(1, 0, 0))); + points.sort(angle); + assertEquals(List.of(new Vector3d(1, 0, 0), new Vector3d(0, 0, 1), new Vector3d(-1, 0, 0), new Vector3d(0, 0, -1)), points); + } + + @Test + void orderIsAppliedInLocalSpace() { + // a helix lying on its side should still be drawn from its own bottom to its own top + Shape helix = new Helix(1, 4, 1); + helix.setOrientation(new Quaterniond().rotateZ(Math.PI / 2)); + helix.setOffset(new Vector3d(5, 0, 0)); + helix.getPointSampler().setOrdering(PointOrder.along(StandardInput.Y)); + assertOrdered(local(helix), PointOrder.along(StandardInput.Y), "local y"); + } + + @Test + void random_isShuffledButTheSameEveryTime() { + Shape circle = new Circle(5); + List generated = new ArrayList<>(circle.getPointSampler().getPoints(circle)); + circle.getPointSampler().setOrdering(PointOrder.RANDOM); + List first = new ArrayList<>(circle.getPointSampler().getPoints(circle)); + assertNotEquals(generated, first, "random order should differ from the generated order"); + assertEquals(generated.size(), first.size()); + + Shape again = new Circle(5); + again.getPointSampler().setOrdering(PointOrder.RANDOM); + assertEquals(first, again.getPointSampler().getPoints(again), "the same shape should be shuffled the same way"); + } + + @Test + void diagonal_goesFromTheLowestToTheHighestCorner() { + Shape sphere = new Sphere(1); + sphere.getPointSampler().setOrdering(PointOrder.DIAGONAL); + List points = sphere.getPointSampler().getPoints(sphere); + Vector3d first = points.getFirst(), last = points.getLast(); + assertTrue(first.x + first.y + first.z < last.x + last.y + last.z); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/shapes/CircleTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/CircleTest.java new file mode 100644 index 0000000..46110c4 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/CircleTest.java @@ -0,0 +1,84 @@ +package com.sovdee.shapes.shapes; + +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CircleTest { + + private static final double DENSITY = 0.25; + + @Test + void calculateCircle_pointsOnRadius() { + List points = new ArrayList<>(); + Circle.calculateCircle(points, 1.0, DENSITY, 2 * Math.PI); + assertFalse(points.isEmpty()); + for (Vector3d p : points) { + double dist = Math.sqrt(p.x * p.x + p.z * p.z); + assertEquals(1.0, dist, 1e-9, "All outline points should lie on the circle radius"); + } + } + + @Test + void calculateCircle_pointCount() { + double radius = 2.0; + List points = new ArrayList<>(); + Circle.calculateCircle(points, radius, DENSITY, 2 * Math.PI); + double expected = (2 * Math.PI) / (DENSITY / radius); + // Allow ±5% tolerance due to floating-point step counting + assertEquals(expected, points.size(), expected * 0.05 + 2); + } + + @Test + void calculateCircle_partialCutoff() { + List full = new ArrayList<>(); + List half = new ArrayList<>(); + Circle.calculateCircle(full, 1.0, DENSITY, 2 * Math.PI); + Circle.calculateCircle(half, 1.0, DENSITY, Math.PI); + // Half cutoff should produce approximately half the points + assertEquals(full.size(), half.size() * 2, (double) full.size() * 0.1 + 2); + } + + @Test + void calculateCircle_yIsZero() { + List points = new ArrayList<>(); + Circle.calculateCircle(points, 1.0, DENSITY, 2 * Math.PI); + for (Vector3d p : points) { + assertEquals(0.0, p.y, 1e-9, "All circle outline points should have Y=0"); + } + } + + @Test + void contains_insideRadius() { + Circle c = new Circle(2.0); + assertTrue(c.contains(new Vector3d(0.5, 0, 0))); + assertTrue(c.contains(new Vector3d(0, 0, 1.9))); + } + + @Test + void contains_outsideRadius() { + Circle c = new Circle(1.0); + assertFalse(c.contains(new Vector3d(2.0, 0, 0))); + assertFalse(c.contains(new Vector3d(1.0, 0, 1.0))); + } + + @Test + void contains_withHeight() { + Circle c = new Circle(1.0, 2.0); + assertTrue(c.contains(new Vector3d(0, 1, 0)), "Inside cylinder vertically"); + assertFalse(c.contains(new Vector3d(0, 3, 0)), "Above top of cylinder"); + assertFalse(c.contains(new Vector3d(0, -0.1, 0)), "Below bottom of cylinder"); + } + + @Test + void setRadius_invalidatesVersion() { + Circle c = new Circle(1.0); + long v0 = c.getVersion(); + c.setRadius(2.0); + assertTrue(c.getVersion() > v0, "setRadius() should increment the version"); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ContainsTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ContainsTest.java new file mode 100644 index 0000000..edc5334 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ContainsTest.java @@ -0,0 +1,227 @@ +package com.sovdee.shapes.shapes; + +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.sampling.SamplingStyle; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link Shape#contains(Vector3d)}: that it follows the shape's transform, and that it tests + * against each shape's actual volume. + */ +class ContainsTest { + + private static Vector3d v(double x, double y, double z) { + return new Vector3d(x, y, z); + } + + /** Shapes with a volume or area, which contain every point of their filled form. */ + private static List solidShapes() { + List shapes = new ArrayList<>(); + shapes.add(new Circle(3)); + shapes.add(new Circle(3, 2)); + shapes.add(new Arc(3, Math.PI / 2)); + shapes.add(new Arc(3, 2, 4.0)); + shapes.add(new Ellipse(4, 1)); + shapes.add(new Ellipse(4, 1, 2)); + shapes.add(new EllipticalArc(4, 2, 2, 2.0)); + shapes.add(new Ellipsoid(4, 1, 2)); + shapes.add(new Sphere(3)); + shapes.add(new SphericalCap(3, 1.0)); + shapes.add(new RegularPolygon(6, 3.0)); + shapes.add(new RegularPolygon(5, 3.0, 2.0)); + shapes.add(new Rectangle(4, 3, Rectangle.Plane.XY)); + shapes.add(new Rectangle(v(1, 0, 1), v(4, 0, 3), Rectangle.Plane.XZ)); + shapes.add(new Cuboid(3, 2, 1)); + shapes.add(new Cuboid(v(10, 10, 10), v(12, 13, 14))); + shapes.add(new Star(1, 3, 5)); + shapes.add(new RegularPolyhedron(3, 4)); + shapes.add(new RegularPolyhedron(3, 8)); + shapes.add(new RegularPolyhedron(3, 12)); + shapes.add(new RegularPolyhedron(3, 20)); + shapes.add(new Heart(3, 4, 3)); + return shapes; + } + + /** Shapes that are only a curve, which contain every point of their outline. */ + private static List curves() { + List shapes = new ArrayList<>(); + shapes.add(new Line(v(1, 2, 3), v(-4, 0, 2))); + shapes.add(new Helix(2, 5, 0.5)); + shapes.add(new BezierCurve(List.of(v(0, 0, 0), v(1, 2, 0), v(3, 0, 1)))); + shapes.add(new IrregularPolygon(List.of(v(0, 0, 0), v(2, 0, 0), v(1, 0, 2)))); + return shapes; + } + + private static void transform(Shape shape) { + shape.setOrientation(new Quaterniond().rotateXYZ(0.7, -1.3, 2.1)); + shape.setScale(1.7); + shape.setOffset(v(40, -25, 60)); + } + + private static void assertContainsItsOwnPoints(AbstractShape shape, SamplingStyle style) { + PointSampler sampler = shape.getPointSampler(); + sampler.setStyle(style); + sampler.setDensity(0.2); + String name = shape.getClass().getSimpleName() + " " + style; + List points = sampler.getPoints(shape); + assertFalse(points.isEmpty(), name); + for (Vector3d point : points) + assertTrue(shape.contains(point, 1e-3), name + " should contain its own point " + point); + } + + // ----- Every shape ----- + + @Test + void shapes_containTheirOwnPoints() { + for (AbstractShape shape : solidShapes()) + assertContainsItsOwnPoints(shape, SamplingStyle.FILL); + for (AbstractShape shape : curves()) + assertContainsItsOwnPoints(shape, SamplingStyle.OUTLINE); + } + + @Test + void shapes_containTheirOwnPoints_whenTransformed() { + for (AbstractShape shape : solidShapes()) { + transform(shape); + assertContainsItsOwnPoints(shape, SamplingStyle.FILL); + } + for (AbstractShape shape : curves()) { + transform(shape); + assertContainsItsOwnPoints(shape, SamplingStyle.OUTLINE); + } + } + + @Test + void shapes_doNotContainDistantPoints() { + List shapes = solidShapes(); + shapes.addAll(curves()); + for (AbstractShape shape : shapes) { + String name = shape.getClass().getSimpleName(); + assertFalse(shape.contains(v(500, 500, 500)), name); + transform(shape); + // where the untransformed shape was + assertFalse(shape.contains(v(0.1, 0.05, 0.1)), name + " should have moved with its offset"); + } + } + + // ----- Transform ----- + + @Test + void contains_followsOffset() { + Sphere sphere = new Sphere(1); + sphere.setOffset(v(10, 0, 0)); + assertTrue(sphere.contains(v(10, 0, 0))); + assertTrue(sphere.contains(v(10.9, 0, 0))); + assertFalse(sphere.contains(v(0, 0, 0))); + } + + @Test + void contains_followsScale() { + Sphere sphere = new Sphere(1); + sphere.setScale(3); + assertTrue(sphere.contains(v(2.9, 0, 0))); + assertFalse(sphere.contains(v(3.1, 0, 0))); + } + + @Test + void contains_followsOrientation() { + Cuboid cuboid = new Cuboid(10, 1, 1); // long along x + assertTrue(cuboid.contains(v(4, 0, 0))); + assertFalse(cuboid.contains(v(0, 4, 0))); + cuboid.setOrientation(new Quaterniond().rotateZ(Math.PI / 2)); // now long along y + assertFalse(cuboid.contains(v(4, 0, 0))); + assertTrue(cuboid.contains(v(0, 4, 0))); + } + + // ----- Actual volumes, not bounding shapes ----- + + @Test + void regularPolygon_usesItsEdges_onAllSides() { + RegularPolygon square = new RegularPolygon(4, 1.0); // vertices on the axes + for (int sx = -1; sx <= 1; sx += 2) { + for (int sz = -1; sz <= 1; sz += 2) { + assertTrue(square.contains(v(0.4 * sx, 0, 0.4 * sz)), "inside, quadrant " + sx + "," + sz); + // inside the circumscribed circle, but beyond the edge + assertFalse(square.contains(v(0.6 * sx, 0, 0.6 * sz)), "outside, quadrant " + sx + "," + sz); + } + } + assertTrue(square.contains(v(0.99, 0, 0))); + assertFalse(square.contains(v(0.4, 0.5, 0.4)), "a flat polygon has no height"); + } + + @Test + void star_excludesTheGapsBetweenItsPoints() { + Star star = new Star(1, 3, 5); + assertTrue(star.contains(v(2.9, 0, 0)), "the tip of a point"); + assertTrue(star.contains(v(0, 0, 0))); + // halfway between two points, an inner vertex sits at radius 1 + double between = Math.PI / 5; + assertFalse(star.contains(v(2 * Math.cos(between), 0, -2 * Math.sin(between))), "the gap between two points"); + assertTrue(star.contains(v(0.9 * Math.cos(between), 0, -0.9 * Math.sin(between)))); + } + + @Test + void polyhedron_usesItsFaces() { + double radius = 3; + RegularPolyhedron octahedron = new RegularPolyhedron(radius, 8); + double inscribed = radius / Math.sqrt(3); + assertTrue(octahedron.contains(v(0, 0, 0))); + assertFalse(octahedron.contains(v(1, 1, 1).normalize().mul(radius * 1.05)), "beyond the circumscribed sphere"); + + // Measure the volume by sampling the cube around it. It must be larger than the inscribed sphere and + // smaller than the circumscribed one: an octahedron of circumradius r has volume (4/3) r^3, + // which is exactly 1/6 of a cube of side 2r. + java.util.Random random = new java.util.Random(7); + int samples = 20_000, insideSphere = 0, insideShape = 0, insideInscribed = 0; + for (int i = 0; i < samples; i++) { + Vector3d point = v(random.nextDouble() * 2 - 1, random.nextDouble() * 2 - 1, random.nextDouble() * 2 - 1).mul(radius); + if (point.length() <= radius) insideSphere++; + if (point.length() <= inscribed) insideInscribed++; + if (octahedron.contains(point)) insideShape++; + } + assertTrue(insideShape > insideInscribed && insideShape < insideSphere, + insideInscribed + " < " + insideShape + " < " + insideSphere); + assertEquals(samples / 6.0, insideShape, samples * 0.02); + } + + @Test + void sphericalCap_isASector() { + SphericalCap cap = new SphericalCap(2, Math.PI / 4); + assertTrue(cap.contains(v(0, 1.9, 0))); + assertTrue(cap.contains(v(0.5, 1, 0))); + assertFalse(cap.contains(v(1.5, 1, 0)), "outside the cone"); + assertFalse(cap.contains(v(0, -1, 0)), "the opposite side"); + } + + @Test + void arc_isLimitedToItsAngle() { + Arc quarter = new Arc(2, Math.PI / 2); + assertTrue(quarter.contains(v(1, 0, 1))); + assertFalse(quarter.contains(v(-1, 0, 1))); + assertFalse(quarter.contains(v(1, 0, -1))); + } + + @Test + void cuboidAndRectangle_fromCorners_containTheirCenter() { + assertTrue(new Cuboid(v(10, 10, 10), v(12, 12, 12)).contains(v(11, 11, 11))); + assertFalse(new Cuboid(v(10, 10, 10), v(12, 12, 12)).contains(v(0, 0, 0))); + assertTrue(new Rectangle(v(10, 0, 10), v(12, 0, 12), Rectangle.Plane.XZ).contains(v(11, 0, 11))); + assertFalse(new Rectangle(v(10, 0, 10), v(12, 0, 12), Rectangle.Plane.XZ).contains(v(0, 0, 0))); + } + + @Test + void curves_useTheTolerance() { + Line line = new Line(v(0, 0, 0), v(10, 0, 0)); + assertTrue(line.contains(v(5, 0, 0))); + assertFalse(line.contains(v(5, 0.01, 0))); + assertTrue(line.contains(v(5, 0.01, 0), 0.05)); + assertFalse(line.contains(v(11, 0, 0), 0.05)); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeCloneTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeCloneTest.java new file mode 100644 index 0000000..a5397c0 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeCloneTest.java @@ -0,0 +1,199 @@ +package com.sovdee.shapes.shapes; + +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ScaleAxes; +import com.sovdee.shapes.modifiers.ScalingModifier; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.sampling.SamplingStyle; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Every draw clones every shape it draws, so a clone has to be an exact, fully independent copy that can still + * reuse the original's cached points. + */ +class ShapeCloneTest { + + private static String name(Shape shape) { + return shape.getClass().getSimpleName(); + } + + private static List copy(List points) { + List out = new ArrayList<>(points.size()); + for (Vector3d p : points) + out.add(new Vector3d(p)); + return out; + } + + private static void assertSamePoints(List expected, List actual, String message) { + assertEquals(expected.size(), actual.size(), message + ": point count"); + for (int i = 0; i < expected.size(); i++) + assertTrue(expected.get(i).equals(actual.get(i), 1e-9), message + ": point " + i + " " + expected.get(i) + " vs " + actual.get(i)); + } + + /** A shape with a non-default transform and sampler setup, so that copying all of it is actually tested. */ + private static Shape configured(Shape shape) { + shape.setOrientation(new Quaterniond().rotateXYZ(0.3, 0.7, -0.2)); + shape.setScale(1.5); + shape.setOffset(new Vector3d(1, -2, 3)); + shape.getPointSampler().setStyle(SamplingStyle.SURFACE); + shape.getPointSampler().setDensity(0.4); + return shape; + } + + @Test + void clone_samplesTheSamePointsForEveryShape() { + for (Shape shape : TestShapes.all()) { + configured(shape); + List original = copy(shape.getPointSampler().getPoints(shape)); + Shape clone = shape.clone(); + assertSame(shape.getClass(), clone.getClass()); + assertSamePoints(original, clone.getPointSampler().getPoints(clone), name(shape)); + } + } + + @Test + void clone_matchesWhenItsCacheIsRegenerated() { + // the clone must copy the shape's own dimensions, not only the cached points + for (Shape shape : TestShapes.all()) { + configured(shape); + List original = copy(shape.getPointSampler().getPoints(shape)); + Shape clone = shape.clone(); + if (clone.getPointSampler() instanceof com.sovdee.shapes.sampling.DefaultPointSampler sampler) + sampler.markDirty(); + assertSamePoints(original, clone.getPointSampler().getPoints(clone), name(shape) + " (regenerated)"); + } + } + + @Test + void clone_reusesTheOriginalsCachedPoints() { + for (Shape shape : TestShapes.all()) { + List points = shape.getPointSampler().getPoints(shape); + Shape clone = shape.clone(); + assertSame(points, clone.getPointSampler().getPoints(clone), + name(shape) + ": an unchanged clone should not resample"); + } + } + + @Test + void clone_copiesTransformAndSamplerSettings() { + Comparator ordering = Comparator.comparingDouble(v -> v.y); + for (Shape shape : TestShapes.all()) { + configured(shape); + shape.getPointSampler().setOrdering(ordering); + Shape clone = shape.clone(); + String n = name(shape); + assertTrue(shape.getOrientation().equals(clone.getOrientation(), 1e-12), n + " orientation"); + assertEquals(shape.getScale(), clone.getScale(), n + " scale"); + assertTrue(shape.getOffset().equals(clone.getOffset(), 1e-12), n + " offset"); + assertEquals(shape.isDynamic(), clone.isDynamic(), n + " dynamic"); + PointSampler a = shape.getPointSampler(), b = clone.getPointSampler(); + assertNotSame(a, b, n + " sampler must be its own"); + assertEquals(a.getStyle(), b.getStyle(), n + " style"); + assertEquals(a.getDensity(), b.getDensity(), n + " density"); + assertEquals(a.isDensityExplicit(), b.isDensityExplicit(), n + " explicit density"); + assertEquals(a.getMaxPoints(), b.getMaxPoints(), n + " max points"); + assertSame(ordering, b.getOrdering(), n + " ordering"); + } + } + + @Test + void changingTheClone_leavesTheOriginalAlone() { + for (Shape shape : TestShapes.all()) { + configured(shape); + List before = shape.getPointSampler().getPoints(shape); + List snapshot = copy(before); + Quaterniond orientation = new Quaterniond(shape.getOrientation()); + Vector3d offset = new Vector3d(shape.getOffset()); + + Shape clone = shape.clone(); + clone.getOrientation().rotateY(1.0); // even mutating the returned instance must not leak + clone.setOrientation(new Quaterniond().rotateX(1.2)); + clone.getOffset().add(5, 5, 5); + clone.setOffset(new Vector3d(-4, 0, 0)); + clone.setScale(0.25); + clone.getPointSampler().setStyle(SamplingStyle.OUTLINE); + clone.getPointSampler().setDensity(0.9); + clone.getPointSampler().getPoints(clone); + + String n = name(shape); + assertTrue(orientation.equals(shape.getOrientation(), 1e-12), n + " orientation leaked"); + assertTrue(offset.equals(shape.getOffset(), 1e-12), n + " offset leaked"); + assertEquals(1.5, shape.getScale(), n + " scale leaked"); + assertEquals(SamplingStyle.SURFACE, shape.getPointSampler().getStyle(), n + " style leaked"); + assertEquals(0.4, shape.getPointSampler().getDensity(), n + " density leaked"); + assertSame(before, shape.getPointSampler().getPoints(shape), n + ": the original should still be cached"); + assertSamePoints(snapshot, shape.getPointSampler().getPoints(shape), n); + } + } + + @Test + void changingTheOriginal_leavesTheCloneAlone() { + for (Shape shape : TestShapes.all()) { + configured(shape); + Shape clone = shape.clone(); + List cloned = copy(clone.getPointSampler().getPoints(clone)); + shape.setOrientation(new Quaterniond().rotateZ(2.0)); + shape.setScale(3); + shape.getPointSampler().setDensity(0.8); + shape.getPointSampler().getPoints(shape); + assertSamePoints(cloned, clone.getPointSampler().getPoints(clone), name(shape)); + } + } + + @Test + void changingACloneDimension_leavesTheOriginalAlone() { + Circle circle = new Circle(2); + List before = circle.getPointSampler().getPoints(circle); + Circle clone = (Circle) circle.clone(); + clone.setRadius(5); + assertEquals(2, circle.getRadius(), 1e-12); + assertEquals(5, clone.getRadius(), 1e-12); + assertSame(before, circle.getPointSampler().getPoints(circle), "the original should still be cached"); + double farthest = clone.getPointSampler().getPoints(clone).stream().mapToDouble(Vector3d::length).max().orElse(0); + assertEquals(5, farthest, 1e-9, "the clone should be sampled at its new radius"); + + Cuboid cuboid = new Cuboid(1, 2, 3); + Cuboid cuboidClone = (Cuboid) cuboid.clone(); + cuboidClone.setLength(10); + assertEquals(1, cuboid.getLength(), 1e-12); + } + + @Test + void clone_deepCopiesModifiers() { + Circle circle = new Circle(2); + ScalingModifier scale = new ScalingModifier(1, 0, StandardInput.Y, ScaleAxes.XZ); + circle.getPointSampler().addModifier(scale); + Circle clone = (Circle) circle.clone(); + + List> cloned = clone.getPointSampler().getModifiers(); + assertEquals(1, cloned.size()); + assertNotSame(scale, cloned.getFirst(), "modifiers must be copied, not shared"); + assertEquals(scale.modifierHash(), cloned.getFirst().modifierHash(), "the copy must be configured the same"); + + clone.getPointSampler().addModifier(new ScalingModifier(0, 1, StandardInput.X, ScaleAxes.XYZ)); + assertEquals(1, circle.getPointSampler().getModifiers().size(), "adding to the clone must not change the original"); + clone.getPointSampler().clearModifiers(); + assertEquals(1, circle.getPointSampler().getModifiers().size(), "clearing the clone must not change the original"); + } + + @Test + void cloneOfAClone_isStillIndependent() { + Circle circle = new Circle(2); + Circle first = (Circle) circle.clone(); + Circle second = (Circle) first.clone(); + second.setRadius(7); + first.setRadius(4); + assertEquals(2, circle.getRadius(), 1e-12); + assertEquals(4, first.getRadius(), 1e-12); + assertEquals(7, second.getRadius(), 1e-12); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeGeometryTest.java b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeGeometryTest.java new file mode 100644 index 0000000..b0e0d33 --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/ShapeGeometryTest.java @@ -0,0 +1,317 @@ +package com.sovdee.shapes.shapes; + +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.sampling.SamplingStyle; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the points each shape generates: counts, extents, duplicates, and density estimates. + */ +class ShapeGeometryTest { + + private static final double DENSITY = 0.25; + + private static List sample(Shape shape, SamplingStyle style, double density) { + PointSampler sampler = shape.getPointSampler(); + sampler.setStyle(style); + sampler.setDensity(density); + return sampler.getPoints(shape); + } + + private static int distinct(List points) { + Set seen = new HashSet<>(); + for (Vector3d v : points) + seen.add(Math.round(v.x * 1e5) + "," + Math.round(v.y * 1e5) + "," + Math.round(v.z * 1e5)); + return seen.size(); + } + + private static List allShapes() { + return TestShapes.all(); + } + + // ----- Every shape ----- + + @Test + void everyShape_generatesFinitePoints() { + for (Shape shape : allShapes()) { + for (SamplingStyle style : SamplingStyle.values()) { + List points = sample(shape, style, DENSITY); + assertFalse(points.isEmpty(), shape.getClass().getSimpleName() + " " + style + " generated no points"); + for (Vector3d point : points) + assertTrue(point.isFinite(), shape.getClass().getSimpleName() + " " + style + " generated " + point); + } + } + } + + @Test + void densityEstimate_isCloseForEveryShape() { + int target = 500; + for (Shape shape : allShapes()) { + for (SamplingStyle style : SamplingStyle.values()) { + double density = shape.computeDensity(style, target); + String name = shape.getClass().getSimpleName() + " " + style; + assertTrue(Double.isFinite(density) && density > 0.001, name + " estimated a density of " + density); + int count = sample(shape, style, density).size(); + // an estimate only has to be in the right region; the sampler refines it from there + assertTrue(count >= target / 4 && count <= target * 4, + name + " estimated " + density + " for " + target + " points, but that gives " + count); + } + } + } + + @Test + void setParticleCount_landsNearTheTargetForEveryShape() { + int target = 500; + for (Shape shape : allShapes()) { + for (SamplingStyle style : SamplingStyle.values()) { + PointSampler sampler = shape.getPointSampler(); + sampler.setStyle(style); + sampler.resetDensity(); + sampler.setParticleCount(shape, target); + int count = sampler.getPointCount(shape); + assertTrue(count >= target * 3 / 4 && count <= target * 5 / 4, + shape.getClass().getSimpleName() + " " + style + " gave " + count + " points for a target of " + target); + } + } + } + + @Test + void outlines_haveNoDuplicatePoints() { + // (an ellipsoid's three outline ellipses cross each other, so it is not included here) + List shapes = List.of(new Circle(3), new Circle(3, 2), new RegularPolygon(6, 3.0), + new RegularPolygon(6, 3.0, 2.0), new Rectangle(4, 3, Rectangle.Plane.XZ), new Cuboid(3, 3, 3), + new Star(1, 3, 5), new RegularPolyhedron(3, 4), new RegularPolyhedron(3, 8), + new RegularPolyhedron(3, 12), new RegularPolyhedron(3, 20), + new IrregularPolygon(List.of(new Vector3d(0, 0, 0), new Vector3d(2, 0, 0), new Vector3d(1, 0, 2)), 2.0)); + for (Shape shape : shapes) { + for (SamplingStyle style : SamplingStyle.values()) { + List points = sample(shape, style, DENSITY); + assertEquals(points.size(), distinct(points), + shape.getClass().getSimpleName() + " " + style + " generated duplicate points"); + } + } + } + + // ----- Polygons and stars ----- + + @Test + void regularPolygon_keepsItsSideCount() { + for (int sides = 3; sides <= 100; sides++) { + assertEquals(sides, new RegularPolygon(sides, 1.0).getSides()); + RegularPolygon polygon = new RegularPolygon(3, 1.0); + polygon.setSides(sides); + assertEquals(sides, polygon.getSides()); + assertEquals(sides, ((RegularPolygon) polygon.clone()).getSides()); + } + } + + @Test + void star_keepsItsPointCount() { + for (int points = 2; points <= 100; points++) { + assertEquals(points, new Star(1, 2, points).getStarPoints()); + assertEquals(points, new Star(1, 2, Math.PI * 2 / points).getStarPoints()); + Star star = new Star(1, 2, 5); + star.setStarPoints(points); + assertEquals(points, ((Star) star.clone()).getStarPoints()); + } + } + + @Test + void regularPolygon_drawsEachSideOnce() { + for (int sides = 3; sides <= 40; sides++) { + RegularPolygon polygon = new RegularPolygon(sides, 5.0); + List points = sample(polygon, SamplingStyle.OUTLINE, DENSITY); + int perSide = (int) Math.max(Math.round(polygon.getSideLength() / DENSITY), 1); + assertEquals(sides * perSide, points.size(), sides + " sides"); + assertEquals(points.size(), distinct(points), sides + " sides"); + } + } + + // ----- Extents ----- + + @Test + void solidSphere_isFilledOutToItsRadius() { + for (double radius : new double[]{0.1, 0.4, 0.7, 1, 2, 5}) { + List points = sample(new Sphere(radius), SamplingStyle.FILL, DENSITY); + assertFalse(points.isEmpty(), "a solid sphere of radius " + radius + " has no points"); + double furthest = 0; + for (Vector3d point : points) + furthest = Math.max(furthest, point.length()); + assertEquals(radius, furthest, 1e-9, "a solid sphere of radius " + radius + " should include its surface"); + } + } + + @Test + void shapesWithHeight_reachTheirTop() { + List shapes = List.of(new Circle(1, 1), new Circle(1, 10), new Ellipse(2, 1, 3), + new RegularPolygon(5, 1.0, 1.0), new Arc(1, 2.5, Math.PI)); + for (LWHShape shape : shapes) { + for (SamplingStyle style : SamplingStyle.values()) { + // 0.3 does not divide these heights evenly + ShapeBounds bounds = ShapeBounds.compute(sample(shape, style, 0.3)); + String name = shape.getClass().getSimpleName() + " " + style; + assertEquals(shape.getHeight(), bounds.maxY(), 1e-9, name + " does not reach its top"); + assertEquals(0, bounds.minY(), 1e-9, name + " does not start at its base"); + } + } + } + + @Test + void helix_spacesPointsByDensity() { + for (double radius : new double[]{0.5, 1, 3, 8}) { + List points = sample(new Helix(radius, 5, 0.5), SamplingStyle.OUTLINE, DENSITY); + double spacing = points.get(0).distance(points.get(1)); + assertEquals(DENSITY, spacing, DENSITY * 0.1, "radius " + radius); + } + } + + // ----- Polyhedra ----- + + @Test + void polyhedra_haveTheRightNumberOfVertices() { + int[][] facesAndVertices = {{4, 4}, {8, 6}, {12, 20}, {20, 12}}; + for (int[] pair : facesAndVertices) { + double radius = 3; + List points = sample(new RegularPolyhedron(radius, pair[0]), SamplingStyle.OUTLINE, DENSITY); + int vertices = 0; + for (Vector3d point : points) { + assertTrue(point.length() <= radius + 1e-6, "a point lies outside the circumscribed sphere"); + if (Math.abs(point.length() - radius) < 1e-6) + vertices++; + } + assertEquals(pair[1], vertices, pair[0] + " faces"); + } + } + + @Test + void polyhedra_haveTheRightNumberOfEdges() { + int[][] facesAndEdges = {{4, 6}, {8, 12}, {12, 30}, {20, 30}}; + for (int[] pair : facesAndEdges) { + RegularPolyhedron polyhedron = new RegularPolyhedron(3, pair[0]); + // at a density of half a side, every edge is its two vertices and one midpoint + List points = sample(polyhedron, SamplingStyle.OUTLINE, polyhedron.getSideLength() / 2); + int vertices = switch (pair[0]) { + case 4 -> 4; + case 8 -> 6; + case 12 -> 20; + default -> 12; + }; + assertEquals(vertices + pair[1], points.size(), pair[0] + " faces"); + } + } + + // ----- Rectangles and cuboids ----- + + @Test + void cuboid_canBeGeneratedWithoutASampler() { + List points = new ArrayList<>(); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> new Cuboid(1, 1, 1).generateOutline(points, DENSITY)); + assertEquals(sample(new Cuboid(1, 1, 1), SamplingStyle.OUTLINE, DENSITY).size(), points.size()); + } + + @Test + void rectangle_canBeGeneratedWithoutASampler() { + List points = new ArrayList<>(); + new Rectangle(4, 4, Rectangle.Plane.XZ).generateOutline(points, 0.1); + assertEquals(sample(new Rectangle(4, 4, Rectangle.Plane.XZ), SamplingStyle.OUTLINE, 0.1).size(), points.size()); + assertEquals(160, points.size()); + } + + @Test + void cuboid_fromCorners_sitsBetweenThem() { + Vector3d a = new Vector3d(10, 20, 30); + Vector3d b = new Vector3d(12, 23, 34); + for (Shape cuboid : List.of(new Cuboid(a, b), new Cuboid(() -> a, () -> b))) { + ShapeBounds bounds = ShapeBounds.compute(sample(cuboid, SamplingStyle.OUTLINE, DENSITY)); + assertEquals(10, bounds.minX(), 1e-9); + assertEquals(12, bounds.maxX(), 1e-9); + assertEquals(20, bounds.minY(), 1e-9); + assertEquals(23, bounds.maxY(), 1e-9); + assertEquals(30, bounds.minZ(), 1e-9); + assertEquals(34, bounds.maxZ(), 1e-9); + } + } + + @Test + void rectangle_fromCorners_sitsBetweenThem() { + Vector3d a = new Vector3d(10, 0, 30); + Vector3d b = new Vector3d(12, 0, 34); + for (Shape rectangle : List.of(new Rectangle(a, b, Rectangle.Plane.XZ), new Rectangle(() -> a, () -> b, Rectangle.Plane.XZ))) { + ShapeBounds bounds = ShapeBounds.compute(sample(rectangle, SamplingStyle.SURFACE, DENSITY)); + assertEquals(10, bounds.minX(), 1e-9); + assertEquals(12, bounds.maxX(), 1e-9); + assertEquals(30, bounds.minZ(), 1e-9); + assertEquals(34, bounds.maxZ(), 1e-9); + } + } + + // ----- Dynamic shapes ----- + + @Test + void dynamicLine_isOnlyResampledWhenItMoves() { + AtomicReference end = new AtomicReference<>(new Vector3d(5, 0, 0)); + Line line = new Line(Vector3d::new, () -> new Vector3d(end.get())); + line.getPointSampler().setDensity(DENSITY); + + List first = line.getPointSampler().getPoints(line); + assertSame(first, line.getPointSampler().getPoints(line), "an unmoved line should not be resampled"); + + end.set(new Vector3d(0, 8, 0)); + List moved = line.getPointSampler().getPoints(line); + assertNotSame(first, moved, "a moved line must be resampled"); + assertEquals(8, moved.get(moved.size() - 1).y, 1e-9); + assertEquals(8, line.getLength(), 1e-9); + } + + @Test + void dynamicShapes_cloneWithoutLosingTheirSuppliers() { + AtomicReference corner = new AtomicReference<>(new Vector3d(2, 2, 2)); + Cuboid cuboid = new Cuboid(Vector3d::new, () -> new Vector3d(corner.get())); + cuboid.getPointSampler().setDensity(DENSITY); + List points = cuboid.getPointSampler().getPoints(cuboid); + + Shape copy = cuboid.clone(); + assertSame(points, copy.getPointSampler().getPoints(copy), "an unmoved clone should reuse the sampled points"); + + corner.set(new Vector3d(4, 4, 4)); + ShapeBounds bounds = ShapeBounds.compute(copy.getPointSampler().getPoints(copy)); + assertEquals(4, bounds.maxX(), 1e-9, "the clone should still follow the suppliers"); + } + + @Test + void dynamicBezier_followsItsSupplier() { + AtomicReference end = new AtomicReference<>(new Vector3d(3, 0, 0)); + BezierCurve curve = new BezierCurve(() -> List.of(new Vector3d(0, 0, 0), new Vector3d(1, 2, 0), new Vector3d(end.get()))); + curve.getPointSampler().setDensity(DENSITY); + + List first = curve.getPointSampler().getPoints(curve); + assertSame(first, curve.getPointSampler().getPoints(curve)); + assertEquals(3, first.get(first.size() - 1).x, 1e-9, "the curve should include its end point"); + + end.set(new Vector3d(6, 0, 0)); + List moved = curve.getPointSampler().getPoints(curve); + assertEquals(6, moved.get(moved.size() - 1).x, 1e-9); + } + + // ----- Transform ----- + + @Test + void setOffset_copiesTheVector() { + Vector3d offset = new Vector3d(1, 0, 0); + Circle circle = new Circle(1); + circle.setOffset(offset); + offset.x = 99; + assertEquals(1, circle.getOffset().x, 1e-12); + } +} diff --git a/shapes-lib/src/test/java/com/sovdee/shapes/shapes/TestShapes.java b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/TestShapes.java new file mode 100644 index 0000000..7bf362a --- /dev/null +++ b/shapes-lib/src/test/java/com/sovdee/shapes/shapes/TestShapes.java @@ -0,0 +1,44 @@ +package com.sovdee.shapes.shapes; + +import org.joml.Vector3d; + +import java.util.ArrayList; +import java.util.List; + +/** + * One instance of every shape type (and the notable variants of each), freshly constructed on every call. + */ +final class TestShapes { + + private TestShapes() {} + + static List all() { + List shapes = new ArrayList<>(); + shapes.add(new Circle(3)); + shapes.add(new Circle(3, 2)); + shapes.add(new Arc(3, Math.PI / 2)); + shapes.add(new Arc(3, 2, Math.PI / 2)); + shapes.add(new Ellipse(4, 1)); + shapes.add(new Ellipse(4, 1, 2)); + shapes.add(new EllipticalArc(4, 2, 2, 2.0)); + shapes.add(new Ellipsoid(4, 1, 2)); + shapes.add(new Sphere(3)); + shapes.add(new SphericalCap(3, 1.0)); + shapes.add(new Helix(2, 5, 1 / (2 * Math.PI))); + shapes.add(new RegularPolygon(6, 3.0)); + shapes.add(new RegularPolygon(6, 3.0, 2.0)); + shapes.add(new Rectangle(4, 3, Rectangle.Plane.XZ)); + shapes.add(new Cuboid(3, 3, 3)); + shapes.add(new Star(1, 3, 5)); + shapes.add(new Line(new Vector3d(5, 0, 0))); + shapes.add(new RegularPolyhedron(3, 4)); + shapes.add(new RegularPolyhedron(3, 8)); + shapes.add(new RegularPolyhedron(3, 12)); + shapes.add(new RegularPolyhedron(3, 20)); + shapes.add(new Heart(3, 3, 3)); + shapes.add(new BezierCurve(List.of(new Vector3d(0, 0, 0), new Vector3d(1, 2, 0), new Vector3d(3, 0, 0)))); + shapes.add(new IrregularPolygon(List.of(new Vector3d(0, 0, 0), new Vector3d(2, 0, 0), new Vector3d(1, 0, 2)))); + shapes.add(new IrregularPolygon(List.of(new Vector3d(0, 0, 0), new Vector3d(2, 0, 0), new Vector3d(1, 0, 2)), 2.0)); + return shapes; + } +} diff --git a/skript-particle/build.gradle b/skript-particle/build.gradle index 4c7c539..2b1119a 100644 --- a/skript-particle/build.gradle +++ b/skript-particle/build.gradle @@ -1,8 +1,33 @@ import org.apache.tools.ant.filters.ReplaceTokens +buildscript { + // The skript-test plugin is only published to mavenLocal for now, so it is optional: + // without it (e.g. on CI) the project still builds and runs its JUnit tests, just not the Skript tests. + def skriptTestMarker = new File(System.getProperty('user.home'), + '.m2/repository/skript-test/skript-test.gradle.plugin/1.0.1') + if (skriptTestMarker.isDirectory()) { + repositories { mavenLocal() } + dependencies { classpath 'skript-test:skript-test.gradle.plugin:1.0.1' } + } +} + plugins { id 'java' id 'com.gradleup.shadow' version '8.3.5' + id("io.papermc.paperweight.userdev") version "2.0.0-beta.24" +} + +if (buildscript.configurations.classpath.dependencies.any { it.group == 'skript-test' }) { + apply plugin: 'skript-test' + skriptTest { + dependsOn build + testScriptDirectory = file("src/test/scripts") + extraPluginsDirectory = layout.buildDirectory.dir("libs") + skriptRepoRef = "2.17.1" + runVanillaTests = false + } +} else { + logger.lifecycle("skript-test plugin not found in mavenLocal; the Skript tests (skriptTest) are unavailable") } group 'com.sovdee' @@ -12,6 +37,7 @@ configurations.configureEach { } repositories { + mavenLocal() mavenCentral() maven { url 'https://repo.papermc.io/repository/maven-public/' @@ -23,9 +49,18 @@ repositories { dependencies { implementation project(':shapes-lib') - compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") - compileOnly("com.github.SkriptLang:Skript:2.14.0-pre1") + paperweight.paperDevBundle("26.3.build.+") + compileOnly("com.github.SkriptLang:Skript:2.17.1") compileOnly "org.joml:joml:${jomlVersion}" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.0' + testImplementation("com.github.SkriptLang:Skript:2.17.1") + testImplementation "org.joml:joml:${jomlVersion}" +} + +tasks.named('test') { + useJUnitPlatform() } processResources { @@ -39,7 +74,7 @@ javadoc { } java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(25)) } shadowJar { diff --git a/skript-particle/logs/latest.log b/skript-particle/logs/latest.log new file mode 100644 index 0000000..e69de29 diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/SkriptParticle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/SkriptParticle.java index 3e54386..1e4541e 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/SkriptParticle.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/SkriptParticle.java @@ -1,19 +1,30 @@ package com.sovdee.skriptparticles; import ch.njol.skript.Skript; -import ch.njol.skript.SkriptAddon; -import ch.njol.skript.bstats.bukkit.Metrics; import ch.njol.skript.util.Version; +import com.sovdee.skriptparticles.skript.SkriptParticleModule; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginManager; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.addon.SkriptAddon; -import java.io.IOException; import java.util.logging.Logger; +/** + * Entry point for the skript-particle plugin. Extends {@link JavaPlugin} and acts as the + * central hub for registering the Skript addon, loading all element classes, and providing + * static logging helpers. + *
+ * On enable, the plugin verifies that Skript 2.16.0 or later is present, registers itself as a + * {@link SkriptAddon}, loads all Skript element classes under {@code com.sovdee.skriptparticles}, + * and initialises bStats metrics. The static {@link #getInstance()} and {@link #getAddonInstance()} + * accessors return {@code null} after the plugin has been disabled. + */ public class SkriptParticle extends JavaPlugin { + private static final Version MINIMUM_SKRIPT_VERSION = new Version(2, 16, 0); + private static SkriptParticle instance; private static SkriptAddon addon; private static Logger logger; @@ -29,34 +40,70 @@ public class SkriptParticle extends JavaPlugin { // gradients // text rendering + /** + * Returns the singleton instance of this plugin, or {@code null} if the plugin has not yet + * been enabled or has already been disabled. + * + * @return the current {@link SkriptParticle} instance, or {@code null} + */ @Nullable public static SkriptParticle getInstance() { return instance; } + /** + * Returns the {@link SkriptAddon} registered for this plugin, or {@code null} if the plugin + * has not yet been enabled or has already been disabled. + * + * @return the registered {@link SkriptAddon}, or {@code null} + */ @Nullable public static SkriptAddon getAddonInstance() { return addon; } + /** + * Logs an informational message to the plugin logger. Does nothing if the logger has not yet + * been initialised (i.e. before {@link #onEnable()} has run). + * + * @param message the message to log + */ public static void info(String message) { if (logger == null) return; logger.info(message); } + /** + * Logs a warning message to the plugin logger. Does nothing if the logger has not yet been + * initialised. + * + * @param message the message to log + */ public static void warning(String message) { if (logger == null) return; logger.warning(message); } + /** + * Logs a severe (error-level) message to the plugin logger. Does nothing if the logger has not + * yet been initialised. + * + * @param message the message to log + */ public static void severe(String message) { if (logger == null) return; logger.severe(message); } + /** + * Logs a debug message at INFO level, but only when Skript's debug mode is active. + * Does nothing if the logger has not yet been initialised. + * + * @param message the message to log + */ public static void debug(String message) { if (logger == null) return; @@ -65,6 +112,11 @@ public static void debug(String message) { } } + /** + * Initialises the plugin. Verifies that Skript 2.16.0+ is present, registers the + * {@link SkriptAddon}, loads all element classes under {@code com.sovdee.skriptparticles}, + * and starts bStats metrics collection. Disables the plugin if any prerequisite check fails. + */ @Override public void onEnable() { final PluginManager manager = this.getServer().getPluginManager(); @@ -74,25 +126,23 @@ public void onEnable() { SkriptParticle.severe("Could not find Skript! Make sure you have it installed and that it properly loaded. Disabling..."); manager.disablePlugin(this); return; - } else if (Skript.getVersion().compareTo(new Version(2, 7, 0)) < 0) { - SkriptParticle.severe("You are running an unsupported version of Skript. Please update to at least Skript 2.7.0. Disabling..."); + } else if (Skript.getVersion().compareTo(MINIMUM_SKRIPT_VERSION) < 0) { + SkriptParticle.severe("You are running an unsupported version of Skript. Please update to at least Skript " + MINIMUM_SKRIPT_VERSION + ". Disabling..."); manager.disablePlugin(this); return; } instance = this; - addon = Skript.registerAddon(this); - addon.setLanguageFileDirectory("lang"); - try { - addon.loadClasses("com.sovdee.skriptparticles"); - } catch (IOException error) { - error.printStackTrace(); - manager.disablePlugin(this); - return; - } + addon = Skript.instance().registerAddon(SkriptParticle.class, this.getName()); + addon.localizer().setSourceDirectories("lang", null); + addon.loadModules(new SkriptParticleModule()); new Metrics(this, 18457); SkriptParticle.info("Successfully enabled skript-particle."); } + /** + * Cleans up plugin state on shutdown. Clears the singleton {@link #instance} and + * {@link #addon} references so they become {@code null} for the remainder of the JVM session. + */ @Override public void onDisable() { instance = null; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffSetOrdering.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffSetOrdering.java deleted file mode 100644 index c22004b..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffSetOrdering.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.sovdee.skriptparticles.elements.effects; - -import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.lang.Effect; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.Shape; -import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.joml.Vector3d; - -import java.util.Comparator; - -@Name("Shape Animation Ordering") -@Description({ - "Controls the order in which the draw animation effect will draw points. Currently WIP, only supports 2 special orderings.", - "lowest-to-highest, which draws from -x -y -z to x y z, and the reverse." -}) -@Examples( - "set the animation order of {_circle} to lowest-to-highest" -) -@Since("1.3.0") -public class EffSetOrdering extends Effect { - - static { - Skript.registerEffect(EffSetOrdering.class, - "set the animation order of %shapes% to (default|1:lowest-to-highest|2:highest-to-lowest)"); - } - - private Expression shapes; - private int order; - - @Override - public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { - shapes = (Expression) expressions[0]; - order = parseResult.mark; - return true; - } - - @Override - protected void execute(Event event) { - @Nullable Comparator order = switch (this.order) { - case 1 -> (o1, o2) -> { - double value1 = o1.x + o1.y + o1.z; - double value2 = o2.x + o2.y + o2.z; - return Double.compare(value1, value2); - }; - case 2 -> (o1, o2) -> { - double value1 = o1.x + o1.y + o1.z; - double value2 = o2.x + o2.y + o2.z; - return -1 * Double.compare(value1, value2); - }; - default -> null; - }; - for (Shape shape : shapes.getArray(event)) { - shape.getPointSampler().setOrdering(order); - } - } - - @Override - public String toString(@Nullable Event event, boolean debug) { - return "set the animation order of " + shapes.toString(event, debug) + " to " + switch (order) { - case 0 -> "default"; - case 1 -> "lowest-to-highest"; - case 2 -> "highest-to-lowest"; - default -> "error"; - }; - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprDrawnShapes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprDrawnShapes.java deleted file mode 100644 index 87cb82a..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprDrawnShapes.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions; - -import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.expressions.base.EventValueExpression; -import ch.njol.skript.lang.ExpressionType; -import com.sovdee.shapes.shapes.Shape; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Drawn Shape") -@Description("Returns the shape that is being drawn by the draw section.") -@Examples({ - "draw the shapes {_shapes::*} at player's head with radius 1:", - "\t# only affects the drawn version, the original shape is not changed", - "\tset radius of drawn shape to 2" -}) -@Since("1.0.0") -public class ExprDrawnShapes extends EventValueExpression { - - static { - Skript.registerExpression(ExprDrawnShapes.class, Shape.class, ExpressionType.SIMPLE, "[the] drawn shape"); - } - - public ExprDrawnShapes() { - super(Shape.class); - } - - @Override - public String toString(@Nullable Event event, boolean debug) { - return "the drawn shape"; - } - -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprLastCreatedParticle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprLastCreatedParticle.java deleted file mode 100644 index 9df8fb4..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprLastCreatedParticle.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions; - -import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; -import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; -import ch.njol.util.Kleenean; -import com.sovdee.skriptparticles.elements.sections.SecParticle; -import com.sovdee.skriptparticles.particles.Particle; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Last Created Particle") -@Description("Returns the last particle created with the custom particle section.") -@Examples("set {_particle} to last created particle") -@Since("1.0.2") -public class ExprLastCreatedParticle extends SimpleExpression { - - static { - Skript.registerExpression(ExprLastCreatedParticle.class, Particle.class, ExpressionType.SIMPLE, "last created [custom] particle"); - } - - @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { - return true; - } - - @Override - @Nullable - protected Particle[] get(Event event) { - return new Particle[]{SecParticle.lastCreatedParticle}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Particle.class; - } - - @Override - public String toString(@Nullable Event e, boolean debug) { - return "last created particle"; - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprBezierCurve.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprBezierCurve.java deleted file mode 100644 index 5816811..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprBezierCurve.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; - -import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; -import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; -import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.BezierCurve; -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; -import com.sovdee.skriptparticles.util.Point; -import com.sovdee.skriptparticles.util.VectorConversion; -import org.bukkit.Location; -import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.jetbrains.annotations.Nullable; -import org.joml.Vector3d; - -import java.util.ArrayList; -import java.util.List; - -@Name("Particle Bezier Curve") -@Description({ - "Creates a bezier curve between the given start and end points, using the given control points to change the curve." -}) -@Examples({ - "set {_shape} to a bezier curve from {a} to {b} with control points {c} and {d}", - "set {_shape} to a curve from player to player's target with control point (location 3 above player)" -}) -@Since("1.3.0") -public class ExprBezierCurve extends SimpleExpression { - - static { - Skript.registerExpression(ExprBezierCurve.class, Shape.class, ExpressionType.COMBINED, - "[a] [bezier] curve from [start] %vector/entity/location% to [end] %vector/entity/location% (with|using) control point[s] %vectors/entities/locations%"); - } - - private Expression start; - private Expression end; - private Expression controlPoints; - - @Override - public boolean init(Expression[] exprs, int matchedPattern, @NonNull Kleenean isDelayed, @NonNull ParseResult parseResult) { - start = exprs[0]; - end = exprs[1]; - controlPoints = exprs[2]; - return true; - } - - @Override - protected Shape @Nullable [] get(@NonNull Event event) { - @Nullable Point startPt = Point.of(this.start.getSingle(event)); - @Nullable Point endPt = Point.of(this.end.getSingle(event)); - if (startPt == null || endPt == null) - return null; - List> controlPts = new ArrayList<>(); - for (Object value : this.controlPoints.getArray(event)) { - controlPts.add(Point.of(value)); - } - - // Use Supplier-based BezierCurve for dynamic control points - BezierCurve curve = getBezierCurve(startPt, controlPts, endPt); - return new Shape[]{curve}; - } - - private static @NonNull BezierCurve getBezierCurve(@NonNull Point startPt, List> controlPts, @NonNull Point endPt) { - BezierCurve curve = new BezierCurve(() -> { - Location origin = startPt.getLocation(); - List result = new ArrayList<>(); - result.add(VectorConversion.toJOML(startPt.getVector(origin))); - for (Point cp : controlPts) - result.add(VectorConversion.toJOML(cp.getVector(origin))); - result.add(VectorConversion.toJOML(endPt.getVector(origin))); - return result; - }); - curve.getPointSampler().setDrawContext(new DrawData()); - return curve; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - @NonNull - public Class getReturnType() { - return Shape.class; - } - - @Override - @NonNull - public String toString(@Nullable Event event, boolean debug) { - return "bezier curve between start " + start.toString(event, debug) + " and end " + end.toString(event, debug) + - " using control points " + controlPoints.toString(event, debug); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolyhedron.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolyhedron.java deleted file mode 100644 index a00c78f..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolyhedron.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; - -import ch.njol.skript.Skript; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; -import ch.njol.skript.lang.Literal; -import ch.njol.skript.lang.SkriptParser; -import ch.njol.skript.lang.util.SimpleExpression; -import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.RegularPolyhedron; -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Particle Regular Polyhedron") -@Description({ - "Creates a regular polyhedron shape with the given radius. The radius must be greater than 0.", - "Valid polyhedra are tetrahedra (4 faces), octahedra (8), dodecahedra (12), and icosahedra (20).", - "", - "Polyhedra currently do not support the particle count expression, only particle density." -}) -@Examples({ - "set {_shape} to a tetrahedron with radius 1", - "set {_shape} to a solid icosahedron with radius 2", - "draw the shape of a tetrahedron with radius 5 at player" -}) -public class ExprRegularPolyhedron extends SimpleExpression { - - static { - Skript.registerExpression(ExprRegularPolyhedron.class, Shape.class, ExpressionType.COMBINED, "[a[n]] [outlined|:hollow|:solid] (:tetra|:octa|:icosa|:dodeca)hedron (with|of) radius %number%"); - } - - private Expression radius; - private int faces; - private SamplingStyle style; - - @Override - public boolean init(Expression[] expressions, int i, Kleenean kleenean, SkriptParser.ParseResult parseResult) { - radius = (Expression) expressions[0]; - faces = parseResult.hasTag("tetra") ? 4 : parseResult.hasTag("octa") ? 8 : parseResult.hasTag("dodeca") ? 12 : 20; - style = parseResult.hasTag("hollow") ? SamplingStyle.SURFACE : parseResult.hasTag("solid") ? SamplingStyle.FILL : SamplingStyle.OUTLINE; - - if (radius instanceof Literal literal && literal.getSingle().doubleValue() <= 0) { - Skript.error("The radius of the polyhedron must be greater than 0. (radius: " + - ((Literal) radius).getSingle().doubleValue() + ")"); - return false; - } - - return true; - } - - @Override - protected @Nullable Shape[] get(Event event) { - if (radius.getSingle(event) == null) - return new Shape[0]; - RegularPolyhedron shape = new RegularPolyhedron(radius.getSingle(event).doubleValue(), faces); - shape.getPointSampler().setStyle(style); - shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; - } - - @Override - public String toString(@Nullable Event event, boolean b) { - return "regular polyhedron with " + faces + " faces with radius " + radius.toString(event, b); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticleDensity.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticleDensity.java deleted file mode 100644 index 59661cc..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticleDensity.java +++ /dev/null @@ -1,125 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; - -import ch.njol.skript.classes.Changer.ChangeMode; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.expressions.base.PropertyExpression; -import ch.njol.skript.expressions.base.SimplePropertyExpression; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.SkriptParser; -import ch.njol.util.Kleenean; -import com.sovdee.shapes.sampling.PointSampler; -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.util.MathUtil; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Shape Particle Density / Particle Count") -@Description({ - "The density at which particles are spawned in a shape. This is in \"particles per meter\" (ppm), and defaults to 4." + - "Be careful with this property, as it can cause lag if set to a high number. It's recommended to not go above 20 or so for this reason. 1000 ppm is the maximum value.", - "Keep in mind that this value scales with dimensions. A 1 meter line with a density of 20 will spawn 20 particles, but a 1 meter cube with a density of 20 will spawn 8,000 particles (2,400 if hollow).", - "Also, be aware that this may not be exact. Some shapes, like cuboids, will tweak the density a bit to ensure particles land exactly on the borders of the shape.", - "", - "This syntax also supports setting the particle count directly, which is the amount of particles spawned in a shape.", - "Note that this is NOT exact. The actual amount of particles spawned will be the closest multiple of the particle count to the amount of particles needed to draw the shape.", - "For example, if the particle count of a solid cube is set to 100, the actual amount of particles spawned will be 125, which is the nearest cubic number.", - "The returned value of this expression will always be the accurate amount of particles spawned in a shape.", - "", - "Changing this will change the particle density or count of the shape. Resetting or deleting it will set it back to the default density (4).", -}) -@Examples({ - "set {_shape}'s particle density to 10", - "set {_shape}'s particle count to 100", - "reset {_shape}'s particle density", - "reset {_shape}'s particle count", -}) -@Since("1.0.0") -public class ExprShapeParticleDensity extends SimplePropertyExpression { - - static { - // most shapes kinda disregard the particle count, but it's still useful for some - // every shape responds to the particle density property pretty well though - PropertyExpression.register(ExprShapeParticleDensity.class, Number.class, "particle (:density|:count)", "shapes"); - } - - private boolean isDensity; - - @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { - isDensity = parseResult.hasTag("density"); - return super.init(exprs, matchedPattern, isDelayed, parseResult); - } - - @Override - public @Nullable Number convert(Shape shape) { - PointSampler sampler = shape.getPointSampler(); - if (isDensity) - return 1 / sampler.getDensity(); - else { - // Return approximate point count based on current density - return (int) Math.round(1 / sampler.getDensity()); - } - } - - @Override - @Nullable - public Class[] acceptChange(ChangeMode mode) { - return switch (mode) { - case ADD, REMOVE, SET, RESET, DELETE -> new Class[]{Number.class}; - case REMOVE_ALL -> new Class[0]; - }; - } - - @Override - public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { - Shape[] shapes = getExpr().getArray(event); - if (shapes.length == 0) - return; - double change = (delta == null) ? 0.25 : ((Number) delta[0]).doubleValue(); - switch (mode) { - case REMOVE: - change = -change; - case ADD: - for (Shape shape : shapes) { - PointSampler sampler = shape.getPointSampler(); - if (isDensity) { - // clamp to 0.001 and 1000, enough to kill the client but not enough to cause an actual error - sampler.setDensity(MathUtil.clamp(1 / (1 / sampler.getDensity() + change), 0.001, 1000)); - } else - // clamp to 1, the minimum amount of particles - sampler.setParticleCount(shape, Math.max(1, (int) (1 / sampler.getDensity()) + (int) change)); - } - break; - case DELETE: - case RESET: - case SET: - change = isDensity ? MathUtil.clamp(1 / change, 0.001, 1000) : Math.max(1, (int) change); - for (Shape shape : shapes) { - PointSampler sampler = shape.getPointSampler(); - if (isDensity) { - sampler.setDensity(change); - } else { - sampler.setParticleCount(shape, (int) change); - } - } - break; - case REMOVE_ALL: - default: - assert false; - } - } - - @Override - public Class getReturnType() { - return Number.class; - } - - @Override - protected String getPropertyName() { - return "particle " + (isDensity ? "density" : "count"); - } - -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeScale.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeScale.java deleted file mode 100644 index 89beadb..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeScale.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; - -import ch.njol.skript.classes.Changer.ChangeMode; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.expressions.base.PropertyExpression; -import ch.njol.skript.expressions.base.SimplePropertyExpression; -import com.sovdee.shapes.shapes.Shape; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Shape Scale") -@Description({ - "Returns the scale of a shape. This is will scale the shape up or down accordingly, with respect to its center.", - "This does not affect the number of particles drawn, so a scaled up shape will look more sparse. A negative scale will flip the shape inside-out.", - "Changing this will scale the shape accordingly. Resetting or deleting it will set it back to the default scale of 1." -}) -@Examples({ - "set {_shape}'s shape scale to 2", - "set shape scale of {_shape} to 0.5", - "set shape scale of {_shape} to 2 * (scale of {_shape})", - "reset {_shape}'s shape scale" -}) -@Since("1.0.0") -public class ExprShapeScale extends SimplePropertyExpression { - - static { - PropertyExpression.register(ExprShapeScale.class, Number.class, "shape scale", "shapes"); - } - - @Override - public @Nullable Number convert(Shape shape) { - return shape.getScale(); - } - - @Override - public @Nullable Class[] acceptChange(ChangeMode mode) { - return switch (mode) { - case SET, RESET, DELETE, ADD, REMOVE -> new Class[]{Number.class}; - case REMOVE_ALL -> new Class[0]; - }; - } - - @Override - public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { - if ((mode == ChangeMode.ADD || mode == ChangeMode.SET || mode == ChangeMode.REMOVE) && (delta == null || delta.length == 0)) - return; - Shape[] shapes = getExpr().getArray(event); - if (shapes.length == 0) - return; - - double change = (delta != null) ? ((Number) delta[0]).doubleValue() : 1; - switch (mode) { - case REMOVE: - change = -change; - case ADD: - for (Shape shape : shapes) { - shape.setScale(shape.getScale() + change); - } - break; - case DELETE: - case RESET: - case SET: - for (Shape shape : shapes) { - shape.setScale(change); - } - break; - case REMOVE_ALL: - default: - assert false; - } - } - - @Override - public Class getReturnType() { - return Number.class; - } - - @Override - protected String getPropertyName() { - return "scale"; - } - -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeStyle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeStyle.java deleted file mode 100644 index f90c45a..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeStyle.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; - -import ch.njol.skript.classes.Changer.ChangeMode; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.expressions.base.PropertyExpression; -import ch.njol.skript.expressions.base.SimplePropertyExpression; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.shapes.shapes.Shape; -import org.bukkit.event.Event; -import org.jetbrains.annotations.Nullable; - -@Name("Shape Style") -@Description({ - "Returns the style of a shape. This determines how the shape is drawn. See the shape style type for more information.", - "Changing this will change the style of the shape accordingly." -}) -@Examples({ - "set style of {_shape} to solid", - "set {_shape}'s style to wireframe", - "set style of {_shape} to hollow" -}) -@Since("1.0.0") -public class ExprShapeStyle extends SimplePropertyExpression { - - static { - PropertyExpression.register(ExprShapeStyle.class, SamplingStyle.class, "style", "shapes"); - } - - @Override - @Nullable - public SamplingStyle convert(Shape shape) { - return shape.getPointSampler().getStyle(); - } - - @Override - @Nullable - public Class[] acceptChange(ChangeMode mode) { - return switch (mode) { - case SET -> new Class[]{SamplingStyle.class}; - case ADD, REMOVE, REMOVE_ALL, DELETE, RESET -> new Class[0]; - }; - } - - @Override - public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { - if (delta == null || delta.length != 1) return; - SamplingStyle style = (SamplingStyle) delta[0]; - for (Shape shape : getExpr().getArray(event)) { - shape.getPointSampler().setStyle(style); - } - } - - @Override - public Class getReturnType() { - return SamplingStyle.class; - } - - @Override - protected String getPropertyName() { - return "style of shape"; - } - -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/package-info.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/package-info.java deleted file mode 100644 index 4786bc5..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -@DefaultQualifier(NonNull.class) -package com.sovdee.skriptparticles.elements; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.framework.qual.DefaultQualifier; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/SecParticle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/SecParticle.java deleted file mode 100644 index eea1480..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/SecParticle.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.sovdee.skriptparticles.elements.sections; - -import ch.njol.skript.Skript; -import ch.njol.skript.config.SectionNode; -import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; -import ch.njol.skript.doc.Name; -import ch.njol.skript.doc.Since; -import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.Section; -import ch.njol.skript.lang.SkriptParser; -import ch.njol.skript.lang.TriggerItem; -import ch.njol.skript.lang.util.SimpleLiteral; -import ch.njol.skript.util.LiteralUtils; -import ch.njol.util.Kleenean; -import com.sovdee.skriptparticles.particles.Particle; -import com.sovdee.skriptparticles.particles.ParticleMotion; -import org.bukkit.event.Event; -import org.bukkit.util.Vector; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; -import org.skriptlang.skript.lang.entry.EntryContainer; -import org.skriptlang.skript.lang.entry.EntryValidator; -import org.skriptlang.skript.lang.entry.util.ExpressionEntryData; - -import java.util.List; - -@Name("Custom Particle Section") -@Description({ - "This section can be used in conjunction with the `last created particle` expression to create custom particles.", - "The particle can be any custom particle from skript-particle or from skbee.", - "Fields include:", - "\tcount: integer - the number of particles to create (required)", - "\toffset: vector - the offset value of the particle. See the Minecraft wiki on /particle for more info. (default: 0, 0, 0)", - "\tvelocity: vector - the velocity of the particle. Can be a vector or a motion (inwards/clockwise/etc.). (default: 0, 0, 0)", - "\textra: number - the extra value of the particle. Forces `count` to be 0 and cannot be combined with `offset`. " + - "See the Minecraft wiki on /particle for more info. (default: 0)", - "\tforce: boolean - whether or not to force the particle to be seen at long range. (default: false)" -}) -@Examples({ - "create a new custom electric spark particle with:", - "\tcount: 10", - "\toffset: vector(1, 1, 1)", - "\textra: 0.2", - "\tforce: true", - "set {_particle} to last created particle", - "", - "create a new custom red dust particle with:", - "\tcount: 0", - "\tvelocity: inwards", - "\textra: 0.5", - "\tforce: true", - "set {_particle} to last created particle", -}) -@Since("1.0.2") -public class SecParticle extends Section { - @Nullable - public static Particle lastCreatedParticle; - private static final EntryValidator validator = EntryValidator.builder() - .addEntryData(new ExpressionEntryData<>("count", new SimpleLiteral<>(1, false), false, Number.class)) - .addEntryData(new ExpressionEntryData<>("offset", null, true, Vector.class)) - .addEntryData(new ExpressionEntryData<>("velocity", null, true, Object.class)) - .addEntryData(new ExpressionEntryData<>("extra", null, true, Number.class)) - .addEntryData(new ExpressionEntryData<>("force", null, true, Boolean.class)) - .build(); - - // Particle section - // create a new %particle% [particle]: - //- count: int - //- offset: vector - //- velocity: vector or inwards/outwards (exclusive w/ offset & count) - //- extra: double - //- - if particle is dust, allow "color: color" and "size: double" - //- force: boolean - static { - Skript.registerSection(SecParticle.class, "create [a] [new] custom %particle% [particle] [with]"); - } - - private Expression particle; - @Nullable - private Expression count; - @Nullable - private Expression offset; - @Nullable - private Expression velocity; - @Nullable - private Expression extra; - @Nullable - private Expression force; - - @SuppressWarnings("unchecked") - @Override - public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult, SectionNode sectionNode, List triggerItems) { - @Nullable EntryContainer entryContainer = validator.validate(sectionNode); - if (entryContainer == null) - return false; - - particle = (Expression) expressions[0]; - count = (Expression) entryContainer.getOptional("count", Expression.class, true); - offset = (Expression) entryContainer.getOptional("offset", Expression.class, true); - extra = (Expression) entryContainer.getOptional("extra", Expression.class, true); - force = (Expression) entryContainer.getOptional("force", Expression.class, true); - - velocity = entryContainer.getOptional("velocity", Expression.class, true); - if (velocity != null) { - velocity = LiteralUtils.defendExpression(velocity); - if (!LiteralUtils.canInitSafely(velocity)){ - Skript.error("Invalid expression for velocity! Must be a vector or a particle motion."); - return false; - } - } - - if (offset != null && velocity != null) { - Skript.error("You cannot have both an offset and a velocity for a particle!"); - return false; - } - - return true; - } - - @Override - @Nullable - protected TriggerItem walk(Event event) { - execute(event); - return walk(event, false); - } - - private void execute(Event event) { - @Nullable ParticleEffect particleEffect = this.particle.getSingle(event); - if (particleEffect == null) - return; - @Nullable Number count = this.count.getSingle(event); - if (count == null) - return; - @Nullable Vector offset = this.offset != null ? this.offset.getSingle(event) : null; - @Nullable Number extra = this.extra != null ? this.extra.getSingle(event) : null; - @Nullable Boolean force = this.force != null ? this.force.getSingle(event) : null; - - Particle particle = Particle.of(particleEffect).count(count.intValue()); - - if (velocity != null) { - @Nullable Object v = velocity.getSingle(event); - if (v instanceof ParticleMotion motion) { - particle.motion(motion); - } else if (v instanceof Vector vector) { - particle.count(0).offset(vector.getX(), vector.getY(), vector.getZ()); - } - } - - if (offset != null) - particle.offset(offset.getX(), offset.getY(), offset.getZ()); - - if (extra != null) - particle.extra(extra.doubleValue()); - - if (force != null) - particle.force(force); - - lastCreatedParticle = particle; - } - - @Override - public String toString(@Nullable Event event, boolean debug) { - return "new custom particle from " + particle.toString(event, debug); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ParticleTypes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ParticleTypes.java deleted file mode 100644 index f4fa9d2..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ParticleTypes.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.sovdee.skriptparticles.elements.types; - -import ch.njol.skript.classes.ClassInfo; -import ch.njol.skript.classes.EnumClassInfo; -import ch.njol.skript.classes.Parser; -import ch.njol.skript.lang.ParseContext; -import ch.njol.skript.lang.util.ContextlessEvent; -import ch.njol.skript.registrations.Classes; -import com.sovdee.skriptparticles.particles.Particle; -import com.sovdee.skriptparticles.particles.ParticleMotion; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.jetbrains.annotations.Nullable; -import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; -import org.skriptlang.skript.lang.converter.Converters; - -public class ParticleTypes { - static { - - // Particle Builder class - Classes.registerClass(new ClassInfo<>(Particle.class, "customparticle") - .user("customparticles?") - .name("Custom Particle") - .description("Represents a particle with extra shape-related data, like motion.") - .parser(new Parser<>() { - - @Nullable - @Override - public Particle parse(String s, ParseContext context) { - return null; - } - - @Override - public boolean canParse(ParseContext context) { - return false; - } - - @Override - public @NonNull String toString(Particle particle, int flags) { - return particle.toString(ContextlessEvent.get(), false); - } - - @Override - public @NonNull String toVariableNameString(Particle particle) { - return "particle:" + toString(particle, 0); - } - }) - ); - - // Particle motion class - Classes.registerClass(new EnumClassInfo<>(ParticleMotion.class, "particlemotion", "particle motions") - .user("particle ?motions?") - .name("Particle Motion") - .description("Represents the motion of a particle relative to a shape.") - ); - - Converters.registerConverter(ParticleEffect.class, Particle.class, Particle::of); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/RotationTypes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/RotationTypes.java deleted file mode 100644 index 2c22bcc..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/RotationTypes.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.sovdee.skriptparticles.elements.types; - -import ch.njol.skript.Skript; -import ch.njol.skript.classes.ClassInfo; -import ch.njol.skript.classes.Parser; -import ch.njol.skript.expressions.base.EventValueExpression; -import ch.njol.skript.lang.ParseContext; -import ch.njol.skript.lang.function.Functions; -import ch.njol.skript.lang.function.Parameter; -import ch.njol.skript.lang.function.SimpleJavaFunction; -import ch.njol.skript.lang.util.SimpleLiteral; -import ch.njol.skript.registrations.Classes; -import ch.njol.skript.registrations.DefaultClasses; -import com.sovdee.skriptparticles.util.Quaternion; -import org.jetbrains.annotations.Nullable; -import org.joml.AxisAngle4f; -import org.joml.Quaternionf; -import org.skriptlang.skript.lang.converter.Converters; - -public class RotationTypes { - static { - if (Classes.getExactClassInfo(Quaternionf.class) == null) { - Classes.registerClass(new ClassInfo<>(Quaternionf.class, "quaternion") - .user("quaternionf?s?") - .name("Quaternion") - .description("Quaternions can be used for shape rotations. They're composed of four values, w, x, y, and z. " + - "See the Quaternion and AxisAngle functions for ways to create them.") - .since("1.0.0") - .parser(new Parser() { - public boolean canParse(ParseContext context) { - return false; - } - - @Override - public String toString(Quaternionf quaternion, int flags) { - return "w:" + Skript.toString(quaternion.w()) + ", x:" + Skript.toString(quaternion.x()) + ", y:" + Skript.toString(quaternion.y()) + ", z:" + Skript.toString(quaternion.z()); - } - - @Override - public String toVariableNameString(Quaternionf quaternion) { - return quaternion.w() + "," + quaternion.x() + "," + quaternion.y() + "," + quaternion.z(); - } - }) - .defaultExpression(new EventValueExpression<>(Quaternionf.class)) - .cloner(quaternion -> { - try { - // Implements cloneable, but doesn't return a Quaternionf. - // org.joml improperly override. Returns Object. - return (Quaternionf) quaternion.clone(); - } catch (CloneNotSupportedException e) { - return null; - } - })); - } - - Converters.registerConverter(Quaternionf.class, Quaternion.class, Quaternion::new); - - if (Functions.getGlobalSignature("quaternion") == null) { - Functions.registerFunction(new SimpleJavaFunction<>("quaternion", new Parameter[]{ - new Parameter<>("x", DefaultClasses.NUMBER, true, new SimpleLiteral(0, true)), - new Parameter<>("y", DefaultClasses.NUMBER, true, new SimpleLiteral(0, true)), - new Parameter<>("z", DefaultClasses.NUMBER, true, new SimpleLiteral(0, true)), - new Parameter<>("w", DefaultClasses.NUMBER, true, new SimpleLiteral(1, true)) - }, Classes.getExactClassInfo(Quaternionf.class), true) { - @Override - public @Nullable Quaternionf[] executeSimple(Object[][] params) { - float w = ((Number) params[0][0]).floatValue(); - float x = ((Number) params[1][0]).floatValue(); - float y = ((Number) params[2][0]).floatValue(); - float z = ((Number) params[3][0]).floatValue(); - return new Quaternionf[]{new Quaternionf(x, y, z, w)}; - } - } - .description("Returns a quaternion from the given x, y, z and w parameters.") - .examples("set {_v} to quaternion(0,0,0,1)") - .since("1.0.0")); - } - - if (Functions.getGlobalSignature("axisAngle") == null) { - Functions.registerFunction(new SimpleJavaFunction<>("axisAngle", new Parameter[]{ - new Parameter<>("angle", DefaultClasses.NUMBER, true, null), - new Parameter<>("x", DefaultClasses.NUMBER, true, null), - new Parameter<>("y", DefaultClasses.NUMBER, true, null), - new Parameter<>("z", DefaultClasses.NUMBER, true, null) - }, Classes.getExactClassInfo(Quaternionf.class), true) { - @Override - public @Nullable Quaternionf[] executeSimple(Object[][] params) { - float angle = ((Number) params[0][0]).floatValue(); - float x = ((Number) params[1][0]).floatValue(); - float y = ((Number) params[2][0]).floatValue(); - float z = ((Number) params[3][0]).floatValue(); - AxisAngle4f axisAngle4f = new AxisAngle4f(angle, x, y, z); - return new Quaternionf[]{new Quaternionf(axisAngle4f)}; - } - } - .description("Returns a quaternion from the given axis and angle parameters. The axis is a vector composed of 3 numbers, x, y, and z, and the angle is the rotation around that axis, in radians.") - .examples("set {_v} to axisAngle(3.14, 1, 0, 0)") - .since("1.0.0")); - } - - if (Functions.getGlobalSignature("axisAngleDegrees") == null) { - Functions.registerFunction(new SimpleJavaFunction<>("axisAngleDegrees", new Parameter[]{ - new Parameter<>("angle", DefaultClasses.NUMBER, true, null), - new Parameter<>("x", DefaultClasses.NUMBER, true, null), - new Parameter<>("y", DefaultClasses.NUMBER, true, null), - new Parameter<>("z", DefaultClasses.NUMBER, true, null) - }, Classes.getExactClassInfo(Quaternionf.class), true) { - @Override - public @Nullable Quaternionf[] executeSimple(Object[][] params) { - float angle = ((Number) params[0][0]).floatValue() * (float) Math.PI / 180; - float x = ((Number) params[1][0]).floatValue(); - float y = ((Number) params[2][0]).floatValue(); - float z = ((Number) params[3][0]).floatValue(); - AxisAngle4f axisAngle4f = new AxisAngle4f(angle, x, y, z); - return new Quaternionf[]{new Quaternionf(axisAngle4f)}; - } - } - .description("Returns a quaternion from the given axis and angle parameters. The axis is a vector composed of 3 numbers, x, y, and z, and the angle is the rotation around that axis, in degrees.") - .examples("set {_v} to axisAngleDegrees(180, 1, 0, 0)") - .since("1.0.0")); - } - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ShapeTypes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ShapeTypes.java deleted file mode 100644 index 57fecf5..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/types/ShapeTypes.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.sovdee.skriptparticles.elements.types; - -import ch.njol.skript.classes.ClassInfo; -import ch.njol.skript.classes.Parser; -import ch.njol.skript.lang.ParseContext; -import ch.njol.skript.registrations.Classes; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.shapes.shapes.CutoffShape; -import com.sovdee.shapes.shapes.LWHShape; -import com.sovdee.shapes.shapes.PolyShape; -import com.sovdee.shapes.shapes.RadialShape; -import com.sovdee.shapes.shapes.Shape; -import org.jetbrains.annotations.Nullable; - -public class ShapeTypes { - static { - // Shape — register the library shape directly - Classes.registerClass(new ClassInfo<>(Shape.class, "shape") - .user("shapes?") - .name("Shape") - .description("Represents an abstract particle shape. E.g. circle, line, etc.") - .parser(new Parser<>() { - - @Override - public Shape parse(String input, ParseContext context) { - return null; - } - - @Override - public boolean canParse(ParseContext context) { - return false; - } - - @Override - public String toString(Shape o, int flags) { - return o.toString(); - } - - @Override - public String toVariableNameString(Shape shape) { - return "shape:" + shape.getPointSampler().getUUID(); - } - }) - .cloner(Shape::clone) - ); - - // RadialShape - Classes.registerClass(new ClassInfo<>(RadialShape.class, "radialshape") - .user("radial ?shapes?") - .name("Radial Shape") - .description("Represents an abstract particle shape that has a radius. E.g. circle, sphere, etc.") - .parser(new Parser<>() { - @Override - public RadialShape parse(String input, ParseContext context) { return null; } - @Override - public boolean canParse(ParseContext context) { return false; } - @Override - public String toString(RadialShape o, int flags) { return o.toString(); } - @Override - public String toVariableNameString(RadialShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } - }) - ); - - // LWHShape - Classes.registerClass(new ClassInfo<>(LWHShape.class, "lwhshape") - .user("lwh ?shapes?") - .name("Length/Width/Height Shape") - .description("Represents an abstract particle shape that has a length, width, and/or height. E.g. cube, cylinder, ellipse, etc.") - .parser(new Parser<>() { - @Override - public LWHShape parse(String input, ParseContext context) { return null; } - @Override - public boolean canParse(ParseContext context) { return false; } - @Override - public String toString(LWHShape o, int flags) { return o.toString(); } - @Override - public String toVariableNameString(LWHShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } - }) - ); - - // CutoffShape - Classes.registerClass(new ClassInfo<>(CutoffShape.class, "cutoffshape") - .user("cutoff ?shapes?") - .name("Cutoff Shape") - .description("Represents an abstract particle shape that has a cutoff angle. E.g. arc, spherical cap, etc.") - .parser(new Parser<>() { - @Override - public CutoffShape parse(String input, ParseContext context) { return null; } - @Override - public boolean canParse(ParseContext context) { return false; } - @Override - public String toString(CutoffShape o, int flags) { return o.toString(); } - @Override - public String toVariableNameString(CutoffShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } - }) - ); - - // PolyShape - Classes.registerClass(new ClassInfo<>(PolyShape.class, "polyshape") - .user("poly ?shapes?") - .name("Polygonal/Polyhedral Shape") - .description( - "Represents an abstract particle shape that is a polygon or polyhedron, with a side length and side count.\n" + - "Irregular shapes are included in this category, but do not support changing either side count or side length." - ) - .parser(new Parser<>() { - @Override - public PolyShape parse(String input, ParseContext context) { return null; } - @Override - public boolean canParse(ParseContext context) { return false; } - @Override - public String toString(PolyShape o, int flags) { return o.toString(); } - @Override - public String toVariableNameString(PolyShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } - }) - ); - - // Style — use standalone SamplingStyle enum - Classes.registerClass(new ClassInfo<>(SamplingStyle.class, "shapestyle") - .user("shape ?styles?") - .name("Shape Style") - .description("Represents the way the shape is drawn. Outlined is a wireframe representation, Surface is filling in all the surfaces of the shape, and Filled is filling in the entire shape.") - .parser(new Parser<>() { - @Override - public @Nullable SamplingStyle parse(String s, ParseContext context) { - s = s.toUpperCase(); - if (s.matches("OUTLINE(D)?") || s.matches("WIREFRAME")) { - return SamplingStyle.OUTLINE; - } else if (s.matches("SURFACE") || s.matches("HOLLOW")) { - return SamplingStyle.SURFACE; - } else if (s.matches("FILL(ED)?") || s.matches("SOLID")) { - return SamplingStyle.FILL; - } - return null; - } - - @Override - public boolean canParse(ParseContext context) { - return true; - } - - @Override - public String toString(SamplingStyle style, int i) { - return style.toString(); - } - - @Override - public String toVariableNameString(SamplingStyle style) { - return "shapestyle:" + style; - } - })); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/Particle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/Particle.java deleted file mode 100644 index 3d16431..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/Particle.java +++ /dev/null @@ -1,225 +0,0 @@ -package com.sovdee.skriptparticles.particles; - -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; -import org.bukkit.Location; -import org.bukkit.World; -import org.bukkit.entity.Player; -import org.bukkit.util.Vector; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.Nullable; -import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; - -import java.util.Collection; -import java.util.List; - -public class Particle extends ParticleEffect { - - private @Nullable ParticleMotion motion; - private @Nullable ParticleGradient gradient; - private @Nullable Shape parent; - private boolean override = false; - - public static Particle of(ParticleEffect effect) { - org.bukkit.Particle effectParticle = effect.particle(); - Particle particle = new Particle(effectParticle); - particle.count(effect.count()); - particle.data(effect.data()); - @Nullable Location loc; - if ((loc = effect.location()) != null) { - particle.location(loc); - } - - particle.offset(effect.offsetX(), effect.offsetY(), effect.offsetZ()); - particle.extra(effect.extra()); - particle.force(effect.force()); - particle.receivers(effect.receivers()); - particle.source(effect.source()); - return particle; - } - - public Particle(org.bukkit.Particle particle) { - super(particle); - } - - public Particle(org.bukkit.Particle particle, ParticleMotion motion) { - super(particle); - this.motion = motion; - } - - public void spawn(Vector delta) { - if (parent == null) return; - DrawData dd = DrawData.of(parent); - if (dd.getLastLocation() == null) return; - if (motion != null) { - Vector yAxis = dd.getLastOrientation().transform(new Vector(0, 1, 0)); - Vector motionVector = motion.getMotionVector(yAxis, delta); - this.offset(motionVector.getX(), motionVector.getY(), motionVector.getZ()); - this.count(0); - } - if (gradient != null) { - color(gradient.calculateColour(delta)); - } - location(dd.getLastLocation().getLocation().add(delta)); - super.spawn(); - } - - @Nullable - public ParticleMotion motion() { - return motion; - } - - public Particle motion(@Nullable ParticleMotion motion) { - this.motion = motion; - return this; - } - - @Nullable - public Shape parent() { - return parent; - } - - public Particle parent(@Nullable Shape parent) { - this.parent = parent; - return this; - } - - @Nullable - public ParticleGradient gradient() { - return gradient; - } - - public Particle gradient(@Nullable ParticleGradient gradient) { - this.gradient = gradient; - return this; - } - - public boolean override() { - return override; - } - - public Particle override(boolean override) { - this.override = override; - return this; - } - - @Contract("-> new") - public Particle clone() { - Particle particle = (Particle) new Particle(this.particle()) - .count(this.count()) - .extra(this.extra()) - .offset(this.offsetX(), this.offsetY(), this.offsetZ()) - .data(this.data()) - .force(this.force()) - .receivers(this.receivers()) - .source(this.source()); - @Nullable Location location = this.location(); - if (location != null) - particle.location(location); - - return particle.motion(this.motion()) - .parent(this.parent()) - .gradient(this.gradient()) - .override(this.override()); - } - - @Override - public String toString() { - return "Particle{" + - "particle=" + this.particle() + - (motion != null ? ", motion=" + motion : "") + - (gradient != null ? ", gradient=" + gradient : "") + - (parent != null ? ", parent=" + parent : "") + - ", override=" + override + - '}'; - } - - // - - @Override - public Particle particle(org.bukkit.Particle particle) { - return (Particle) super.particle(particle); - } - - @Override - public Particle allPlayers() { - return (Particle) super.allPlayers(); - } - - @Override - public Particle receivers(@Nullable List receivers) { - return (Particle) super.receivers(receivers); - } - - @Override - public Particle receivers(@Nullable Collection receivers) { - return (Particle) super.receivers(receivers); - } - - @Override - public Particle receivers(Player @Nullable ... receivers) { - return (Particle) super.receivers(receivers); - } - - @Override - public Particle receivers(int radius) { - return (Particle) super.receivers(radius); - } - - @Override - public Particle receivers(int radius, boolean byDistance) { - return (Particle) super.receivers(radius, byDistance); - } - - @Override - public Particle receivers(int xzRadius, int yRadius) { - return (Particle) super.receivers(xzRadius, yRadius); - } - - @Override - public Particle receivers(int xzRadius, int yRadius, boolean byDistance) { - return (Particle) super.receivers(xzRadius, yRadius, byDistance); - } - - @Override - public Particle receivers(int xRadius, int yRadius, int zRadius) { - return (Particle) super.receivers(xRadius, yRadius, zRadius); - } - - @Override - public Particle source(@Nullable Player source) { - return (Particle) super.source(source); - } - - @Override - public Particle location(Location location) { - return (Particle) super.location(location); - } - - @Override - public Particle location(World world, double x, double y, double z) { - return (Particle) super.location(world, x, y, z); - } - - @Override - public Particle count(int count) { - return (Particle) super.count(count); - } - - @Override - public Particle offset(double offsetX, double offsetY, double offsetZ) { - return (Particle) super.offset(offsetX, offsetY, offsetZ); - } - - @Override - public ParticleEffect extra(double extra) { - return (ParticleEffect) super.extra(extra); - } - - @Override - public Particle force(boolean force) { - return (Particle) super.force(force); - } - // - -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleGradient.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleGradient.java deleted file mode 100644 index e6d8c3f..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleGradient.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.sovdee.skriptparticles.particles; - -import ch.njol.skript.util.ColorRGB; -import com.sovdee.skriptparticles.util.Quaternion; -import org.bukkit.Color; -import org.bukkit.util.Vector; - -import java.util.ArrayList; -import java.util.List; - -public class ParticleGradient { - - private final Quaternion orientation = new Quaternion(1, 0, 0, 0); - private final List points = new ArrayList<>(); - private boolean local = false; - - public Color calculateColour(Vector delta) { - if (local) { - orientation.transform(delta); - } - - double weightTotal = 0; - double[] rgb = new double[]{0, 0, 0}; - double weight; - Color colour; - for (Point point : points) { - weight = (1 / (point.getPosition().clone().subtract(delta).length())); - weightTotal += weight; - colour = point.getColor(); - rgb[0] += weight * colour.getRed(); - rgb[1] += weight * colour.getGreen(); - rgb[2] += weight * colour.getBlue(); - } - return Color.fromRGB((int) (rgb[0] / weightTotal), (int) (rgb[1] / weightTotal), (int) (rgb[2] / weightTotal)); - } - - public Quaternion getOrientation() { - return orientation; - } - - public void setOrientation(Quaternion orientation) { - this.orientation.set(orientation.clone()); - } - - public List getPoints() { - return points; - } - - public void setPoints(List points) { - this.points.clear(); - this.points.addAll(points); - } - - public void addPoint(Vector position, Color color) { - points.add(new Point(position, color)); - } - - public boolean isLocal() { - return local; - } - - public void setLocal(boolean local) { - this.local = local; - } - - @Override - public String toString() { - return "ParticleGradient{" + - "orientation=" + orientation + - ", points=" + points + - ", local=" + local + - '}'; - } - - public static class Point { - - private Vector position; - private Color color; - - public Point(Vector position, Color color) { - this.position = position; - this.color = color; - } - - public Point(Vector position, ColorRGB color) { - this.position = position; - this.color = color.asBukkitColor(); - } - - public Vector getPosition() { - return position; - } - - public void setPosition(Vector position) { - this.position = position; - } - - public Color getColor() { - return color; - } - - public void setColor(Color color) { - this.color = color; - } - - @Override - public String toString() { - return "Point{" + - "position=" + position + - ", color=" + color + - '}'; - } - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleMotion.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleMotion.java deleted file mode 100644 index a60efb7..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/ParticleMotion.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.sovdee.skriptparticles.particles; - - -import org.bukkit.util.Vector; - -public enum ParticleMotion { - CLOCKWISE, - COUNTERCLOCKWISE, - INWARDS, - OUTWARDS, - NONE; - - private static final Vector DEFAULT_MOTION = new Vector(0, 0, 0); - - public Vector getMotionVector(Vector axis, Vector point) { - return switch (this) { - case NONE -> DEFAULT_MOTION.clone(); - case CLOCKWISE -> getAntiClockwiseMotion(axis, point).multiply(-1); - case COUNTERCLOCKWISE -> getAntiClockwiseMotion(axis, point); - case INWARDS -> getOutwardsMotion(point).multiply(-1); - case OUTWARDS -> getOutwardsMotion(point); - }; - } - - private Vector getAntiClockwiseMotion(Vector axis, Vector point) { - return axis.getCrossProduct(point).normalize(); - } - - private Vector getOutwardsMotion(Vector point) { - return point.clone().normalize(); - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/package-info.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/package-info.java deleted file mode 100644 index cc50bf3..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/particles/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -@DefaultQualifier(NonNull.class) -package com.sovdee.skriptparticles.particles; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.framework.qual.DefaultQualifier; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/BukkitParticleRenderer.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/BukkitParticleRenderer.java new file mode 100644 index 0000000..9ce3a0a --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/BukkitParticleRenderer.java @@ -0,0 +1,78 @@ +package com.sovdee.skriptparticles.rendering; + +import com.sovdee.shapes.sampling.ShapeRenderer; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.List; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; + +/** + * A {@link ShapeRenderer} that spawns particles through the Bukkit API, one call per player per point. + *

+ * This is the fallback for servers where {@link NMSParticleRenderer} is unavailable. It is slower, but honours + * the same per-point render context: visibility, displacement, motion, and particle overrides. + */ +public class BukkitParticleRenderer implements ShapeRenderer { + + private final List recipients; + private final double baseX, baseY, baseZ; + private final double defaultOffsetX, defaultOffsetY, defaultOffsetZ; + private final int defaultCount; + private final double speed; + private final boolean force; + private final ParticleRenderContext context; + + /** + * @param particle the particle whose settings (type, data, count, offset, speed) are the defaults + * @param recipients the players to spawn the particles for, already filtered by distance + * @param baseLoc the location the points are relative to + */ + public BukkitParticleRenderer(ParticleEffect particle, List recipients, Location baseLoc) { + this.recipients = recipients; + this.baseX = baseLoc.getX(); + this.baseY = baseLoc.getY(); + this.baseZ = baseLoc.getZ(); + this.defaultOffsetX = particle.offsetX(); + this.defaultOffsetY = particle.offsetY(); + this.defaultOffsetZ = particle.offsetZ(); + this.defaultCount = particle.count(); + this.speed = particle.speedX(); + this.force = particle.force(); + this.context = new ParticleRenderContext(particle); + } + + @Override + public ParticleRenderContext getContext() { + return context; + } + + @Override + public void begin(int totalPoints) {} + + @Override + public void renderPoint(ParticleRenderContext context) { + if (!context.visible) return; + + double x = baseX + context.x + context.displacementX; + double y = baseY + context.y + context.displacementY; + double z = baseZ + context.z + context.displacementZ; + + double offsetX = defaultOffsetX, offsetY = defaultOffsetY, offsetZ = defaultOffsetZ; + int count = defaultCount; + // a count of zero makes the client treat the offset as a velocity + if (context.hasMotion) { + offsetX = context.motionX; + offsetY = context.motionY; + offsetZ = context.motionZ; + count = 0; + } + + for (Player player : recipients) { + player.spawnParticle(context.particle, x, y, z, count, offsetX, offsetY, offsetZ, speed, context.data, force); + } + } + + @Override + public void end() {} +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawData.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawData.java new file mode 100644 index 0000000..b0ac33a --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawData.java @@ -0,0 +1,255 @@ +package com.sovdee.skriptparticles.rendering; + +import com.sovdee.shapes.sampling.DrawContext; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.util.DynamicLocation; +import com.sovdee.skriptparticles.util.Quaternion; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; + +/** + * Plugin-side rendering metadata attached to library shapes via {@link DrawContext}. + * Holds particle, location, animation, and debug axis state. + * Render modifiers live on the shape's {@link com.sovdee.shapes.sampling.PointSampler}. + */ +public class DrawData implements DrawContext { + + private ParticleEffect particle; + private @Nullable DynamicLocation location; + private @Nullable DynamicLocation lastLocation; + private final Quaternion lastOrientation; + private long animationDuration = 0; + private boolean drawLocalAxes = false; + private boolean drawGlobalAxes = false; + /** + * Set to true after a large-point-count warning has been emitted for this shape. + * Shared with copies of this DrawData, so a shape that is copied for every draw only warns once. + */ + private AtomicBoolean largeSizeWarned = new AtomicBoolean(false); + + /** + * Creates a new {@code DrawData} with default settings: a FLAME particle with zero extra + * speed, no location, and an identity last-orientation quaternion. + */ + public DrawData() { + this.particle = ParticleEffect.of(org.bukkit.Particle.FLAME); + // not chained: ParticleEffect only returns itself from speed() on Skript 2.17+ + this.particle.speed(0); + this.lastOrientation = Quaternion.IDENTITY.clone(); + } + + /** + * Gets the DrawData attached to a shape's PointSampler, creating and attaching one if missing. + */ + public static DrawData of(Shape shape) { + DrawContext ctx = shape.getPointSampler().getDrawContext(); + if (ctx instanceof DrawData dd) return dd; + DrawData dd = new DrawData(); + shape.getPointSampler().setDrawContext(dd); + return dd; + } + + // ---- Particle ---- + + /** + * Returns a copy of the particle stored in this draw data. Callers may modify the returned + * instance without affecting the stored particle. + * + * @return a copy of the particle + */ + public ParticleEffect getParticle() { + return particle.copy(); + } + + /** + * Replaces the stored particle with the given instance. + * + * @param particle the new particle to store + */ + public void setParticle(ParticleEffect particle) { + this.particle = particle; + } + + // ---- Location ---- + + /** + * Returns a clone of the target draw location, or {@code null} if no location has been set. + * + * @return a cloned {@link DynamicLocation}, or {@code null} + */ + @Nullable + public DynamicLocation getLocation() { + if (location == null) return null; + return location.clone(); + } + + /** + * Sets the target draw location for this shape. + * + * @param location the {@link DynamicLocation} to draw at + */ + public void setLocation(DynamicLocation location) { + this.location = location; + } + + /** + * Returns the location that was active during the most recent draw call, or {@code null} if + * the shape has not yet been drawn. + * + * @return the last draw location, or {@code null} + */ + @Nullable + public DynamicLocation getLastLocation() { + return lastLocation; + } + + /** + * Records the location used during the most recent draw call. Called internally by + * {@link DrawManager}. + * + * @param lastLocation the location to store as the last draw location, or {@code null} + */ + public void setLastLocation(@Nullable DynamicLocation lastLocation) { + this.lastLocation = lastLocation; + } + + // ---- Orientation ---- + + /** + * Returns the combined orientation quaternion that was used during the most recent draw call. + * The returned instance is the live object; mutating it affects the stored value. + * + * @return the last draw orientation as a {@link Quaternion} + */ + public Quaternion getLastOrientation() { + return lastOrientation; + } + + /** + * Updates the stored last-orientation quaternion to match the given value. + * + * @param orientation the orientation to store + */ + public void setLastOrientation(Quaternion orientation) { + this.lastOrientation.set(orientation); + } + + // ---- Animation ---- + + /** + * Returns the animation duration in milliseconds. A value of 0 means the shape is drawn + * instantaneously rather than animated. + * + * @return the animation duration in milliseconds + */ + public long getAnimationDuration() { + return animationDuration; + } + + /** + * Sets the animation duration in milliseconds. Set to 0 to disable animation. + * + * @param animationDuration the animation duration in milliseconds + */ + public void setAnimationDuration(long animationDuration) { + this.animationDuration = animationDuration; + } + + // ---- Axes ---- + + /** + * Returns {@code true} if the shape's local orientation axes should be drawn as debug + * particles after rendering the shape. + * + * @return {@code true} if local axes are drawn + */ + public boolean showLocalAxes() { + return drawLocalAxes; + } + + /** + * Sets whether the shape's local orientation axes should be drawn as debug particles. + * + * @param show {@code true} to draw local axes + */ + public void showLocalAxes(boolean show) { + this.drawLocalAxes = show; + } + + /** + * Returns {@code true} if the world-space global axes should be drawn as debug particles + * at the shape's origin after rendering. + * + * @return {@code true} if global axes are drawn + */ + public boolean showGlobalAxes() { + return drawGlobalAxes; + } + + /** + * Sets whether the global (world-space) axes should be drawn as debug particles. + * + * @param show {@code true} to draw global axes + */ + public void showGlobalAxes(boolean show) { + this.drawGlobalAxes = show; + } + + // ---- Warning flag ---- + + /** + * Returns {@code true} if a large-particle-count warning has already been emitted for this + * shape during the current session. Used to prevent repeated warnings. + * + * @return {@code true} if the large-size warning has been emitted + */ + public boolean isLargeSizeWarned() { + return largeSizeWarned.get(); + } + + /** + * Sets the large-size warning flag to suppress duplicate warnings. + * + * @param warned {@code true} to mark the warning as already emitted + */ + public void setLargeSizeWarned(boolean warned) { + this.largeSizeWarned.set(warned); + } + + /** + * Marks the large-size warning as emitted. + * + * @return {@code true} if it had not been emitted before, i.e. if the caller should emit it now + */ + public boolean markLargeSizeWarned() { + return largeSizeWarned.compareAndSet(false, true); + } + + // ---- DrawContext ---- + + /** + * Creates a deep copy of this {@code DrawData}, duplicating the particle, location, + * last location, last orientation, animation duration, and axis-draw flags. The + * {@link #isLargeSizeWarned()} flag is shared with the copy, so the warning is emitted once for a shape + * and all of its copies. + * + * @return a new {@code DrawData} with the same settings as this instance + */ + @Override + public DrawData copy() { + DrawData copy = new DrawData(); + copy.particle = this.particle.copy(); + if (this.location != null) + copy.location = this.location.clone(); + if (this.lastLocation != null) + copy.lastLocation = this.lastLocation.clone(); + copy.lastOrientation.set(this.lastOrientation); + copy.animationDuration = this.animationDuration; + copy.drawLocalAxes = this.drawLocalAxes; + copy.drawGlobalAxes = this.drawGlobalAxes; + copy.largeSizeWarned = this.largeSizeWarned; + return copy; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawManager.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawManager.java new file mode 100644 index 0000000..7717287 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/DrawManager.java @@ -0,0 +1,191 @@ +package com.sovdee.skriptparticles.rendering; + +import com.sovdee.shapes.sampling.RenderPass; +import com.sovdee.shapes.sampling.ShapeRenderer; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.SkriptParticle; +import com.sovdee.skriptparticles.util.DynamicLocation; +import com.sovdee.skriptparticles.util.MathUtil; +import com.sovdee.skriptparticles.util.ParticleUtil; +import com.sovdee.skriptparticles.util.Quaternion; +import com.sovdee.skriptparticles.util.VectorConversion; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitRunnable; +import org.joml.Quaterniond; +import org.joml.Vector3d; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; + +/** + * Static draw methods for rendering library shapes with plugin DrawData. + *

+ * Drawing does not modify the shape or its {@link DrawData}: everything a draw needs is resolved up front + * and kept local to that draw, so the same shape can safely be drawn from several threads or locations. + */ +public class DrawManager { + + /** + * Maximum distance at which a player can see non-forced particles. + * Matches the vanilla client's particle render distance of 32 blocks. + */ + private static final double MAX_PARTICLE_DISTANCE = 32; + + /** + * Maximum distance at which a player can see forced particles (256 blocks). + */ + private static final double MAX_FORCED_PARTICLE_DISTANCE = 256; + + /** + * Drawing more particles than this in one call earns a warning in the console. + */ + private static final int LARGE_PARTICLE_THRESHOLD = 16_000; + + private static final boolean USE_NMS = isNMSAvailable(); + + private static boolean isNMSAvailable() { + try { + return NMSParticleRenderer.isAvailable(); + } catch (Throwable e) { + // The renderer's classes cannot even be loaded on this server (e.g. different mappings). + SkriptParticle.warning("Packet-based particle rendering is unavailable on this server (" + e + "). " + + "Falling back to the Bukkit API, which is slower."); + return false; + } + } + + /** + * Draws a shape at its own stored location (from {@link DrawData}). + * Does nothing if the shape has no location. + * + * @param shape the shape to draw + * @param recipients the players who should see the particles + */ + public static void draw(Shape shape, Collection recipients) { + draw(shape, new DynamicLocation(), recipients); + } + + /** + * Draws a shape at the given location, using the shape's own orientation and particle. + * If the location is {@linkplain DynamicLocation#isNull() null}, the shape's own stored location is used + * instead; if it has none, nothing is drawn. + *

+ * If the shape's {@link DrawData} has an animation duration, the particles are spread out over that time + * on an asynchronous timer. Otherwise they are all sent immediately, on the calling thread. + * + * @param shape the shape to draw + * @param location the location to draw the shape at + * @param recipients the players who should see the particles + */ + public static void draw(Shape shape, DynamicLocation location, Collection recipients) { + DrawData dd = DrawData.of(shape); + + if (location.isNull()) { + DynamicLocation shapeLocation = dd.getLocation(); + if (shapeLocation == null || shapeLocation.isNull()) return; + location = shapeLocation; + } + Location center = location.getLocation(); + if (center.getWorld() == null) return; + + // a private copy: rendering reads the particle's settings while other draws may be using it too + ParticleEffect particle = dd.getParticle(); + Quaterniond orientation = shape.getOrientation(); + List points = shape.getPointSampler().getPoints(shape, orientation); + if (points.isEmpty()) return; + + if (points.size() > LARGE_PARTICLE_THRESHOLD && dd.markLargeSizeWarned()) { + SkriptParticle.warning("A shape is drawing " + points.size() + " particles in one call. " + + "This may cause lag. Consider reducing its particle density or size."); + } + + // Filter recipients once instead of per point. A player can see the shape if they are within + // particle range of any part of it, so the range is extended by how far the shape reaches. + double reach = shape.getPointSampler().boundsOf(points).maxDistance(); + List filteredRecipients = filterRecipients(recipients, center, particle.force(), reach); + + if (!filteredRecipients.isEmpty()) { + ShapeRenderer renderer = USE_NMS + ? new NMSParticleRenderer(particle, filteredRecipients, center) + : new BukkitParticleRenderer(particle, filteredRecipients, center); + ParticleRenderContext context = renderer.getContext(); + context.orientation = orientation; + context.scale = shape.getScale(); + RenderPass pass = shape.getPointSampler().preparePass(shape, points, context); + + long animationDuration = dd.getAnimationDuration(); + if (animationDuration > 0) { + drawAnimated(points, animationDuration, pass, renderer); + } else { + pass.renderBatch(points, renderer); + } + + if (dd.showLocalAxes() || dd.showGlobalAxes()) { + Location axesOrigin = center.clone().add(VectorConversion.toBukkit(shape.getOffset())); + if (dd.showLocalAxes()) + ParticleUtil.drawAxes(axesOrigin, new Quaternion(orientation.x, orientation.y, orientation.z, orientation.w), filteredRecipients); + if (dd.showGlobalAxes()) + ParticleUtil.drawAxes(axesOrigin, Quaternion.IDENTITY, filteredRecipients); + } + } + } + + /** + * Renders the points in batches, one batch per tick, so that the whole shape takes + * {@code animationDuration} milliseconds to appear. Stops at the first failure. + */ + private static void drawAnimated(List points, long animationDuration, + RenderPass pass, + ShapeRenderer renderer) { + Plugin plugin = SkriptParticle.getInstance(); + if (plugin == null) return; + + double millisecondsPerPoint = animationDuration / (double) points.size(); + Iterator> batchIterator = MathUtil.batch(points, millisecondsPerPoint).iterator(); + + BukkitRunnable runnable = new BukkitRunnable() { + @Override + public void run() { + if (!batchIterator.hasNext()) { + this.cancel(); + return; + } + try { + pass.renderBatch(batchIterator.next(), renderer); + } catch (RuntimeException e) { + // every remaining batch would fail the same way, so report it once and stop + this.cancel(); + SkriptParticle.severe("Failed to draw an animated shape, stopping the animation: " + e.getMessage()); + } + } + }; + runnable.runTaskTimerAsynchronously(plugin, 0, 1); + } + + /** + * Returns the online recipients, in the right world, who are close enough to see some part of a shape. + * + * @param center the location the shape is drawn around + * @param force whether the particles are forced, which extends how far away they can be seen + * @param reach how far the shape extends from its center + */ + private static List filterRecipients(Collection recipients, Location center, boolean force, double reach) { + double maxDistance = (force ? MAX_FORCED_PARTICLE_DISTANCE : MAX_PARTICLE_DISTANCE) + reach; + double maxDistanceSquared = maxDistance * maxDistance; + List filtered = new ArrayList<>(recipients.size()); + for (Player player : recipients) { + if (!player.isOnline()) continue; + Location playerLoc = player.getLocation(); + if (!center.getWorld().equals(playerLoc.getWorld())) continue; + if (center.distanceSquared(playerLoc) <= maxDistanceSquared) { + filtered.add(player); + } + } + return filtered; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/NMSParticleRenderer.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/NMSParticleRenderer.java new file mode 100644 index 0000000..fa23405 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/NMSParticleRenderer.java @@ -0,0 +1,277 @@ +package com.sovdee.skriptparticles.rendering; + +import com.sovdee.shapes.sampling.ShapeRenderer; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBundlePacket; +import net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket; +import net.minecraft.server.level.ServerPlayer; +import org.bukkit.Location; +import org.bukkit.craftbukkit.CraftParticle; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; + +/** + * Sends particles via NMS packets, using direct ByteBuf pipeline injection for optimal performance. + *

+ * Implements {@link ShapeRenderer} so the library's render loop calls {@link #renderPoint} per-point. + * Bundles up to 4096 particles per frame using bundle delimiters. + *

+ * Handles version differences in the packet constructor: + *

    + *
  • 1.21.0 - 1.21.3: {@code (T, boolean overrideLimiter, double x,y,z, float xDist,yDist,zDist,maxSpeed, int count)}
  • + *
  • 1.21.4+: {@code (T, boolean overrideLimiter, boolean alwaysShow, double x,y,z, float xDist,yDist,zDist,maxSpeed, int count)}
  • + *
+ */ +public class NMSParticleRenderer implements ShapeRenderer { + + private static final Logger LOGGER = Logger.getLogger(NMSParticleRenderer.class.getName()); + + /** + * Maximum number of packets per bundle. The protocol supports up to 4096. + */ + private static final int MAX_BUNDLE_SIZE = 4096; + + /** + * Warn when a shape draws more than this many particles in a single call. + */ + /** + * How many distinct particle data objects to keep converted options for. + * Gradients use at most 256; anything producing more than this is not worth caching. + */ + private static final int MAX_CACHED_OPTIONS = 1024; + + /** + * Functional interface for packet creation, eliminating branching in hot loop. + */ + @FunctionalInterface + private interface PacketFactory { + ClientboundLevelParticlesPacket create( + ParticleOptions options, boolean force, + double x, double y, double z, + float xDist, float yDist, float zDist, float maxSpeed, int count + ) throws Throwable; + } + + /** + * Cached packet factory using the version-appropriate constructor. + * Null if reflection lookup failed (will fall back to Bukkit API). + */ + private static final @Nullable PacketFactory PACKET_FACTORY; + + static { + PacketFactory factory = null; + + MethodHandles.Lookup lookup = MethodHandles.lookup(); + try { + // Try the 2-boolean constructor first (1.21.4+) + MethodHandle ctor = lookup.findConstructor(ClientboundLevelParticlesPacket.class, MethodType.methodType( + void.class, + ParticleOptions.class, boolean.class, boolean.class, + double.class, double.class, double.class, + float.class, float.class, float.class, float.class, + int.class + )); + factory = (options, force, x, y, z, xDist, yDist, zDist, maxSpeed, count) -> + (ClientboundLevelParticlesPacket) ctor.invokeExact( + options, force, force, x, y, z, xDist, yDist, zDist, maxSpeed, count + ); + } catch (NoSuchMethodException | IllegalAccessException e) { + try { + // Fall back to 1-boolean constructor (1.21.0 - 1.21.3) + //noinspection JavaLangInvokeHandleSignature + MethodHandle ctor = lookup.findConstructor(ClientboundLevelParticlesPacket.class, MethodType.methodType( + void.class, + ParticleOptions.class, boolean.class, + double.class, double.class, double.class, + float.class, float.class, float.class, float.class, + int.class + )); + factory = (options, force, x, y, z, xDist, yDist, zDist, maxSpeed, count) -> + (ClientboundLevelParticlesPacket) ctor.invokeExact( + options, force, x, y, z, xDist, yDist, zDist, maxSpeed, count + ); + } catch (NoSuchMethodException | IllegalAccessException ex) { + LOGGER.log(Level.WARNING, "Could not find ClientboundLevelParticlesPacket constructor. " + + "NMS packet bundling will be unavailable.", ex); + } + } + + PACKET_FACTORY = factory; + } + + /** + * Returns true if NMS packet sending is available on this server version. + */ + public static boolean isAvailable() { + return PACKET_FACTORY != null; + } + + // ---- Instance fields (set up in constructor, used across begin/renderPoint/end) ---- + + private final List serverPlayers; + private final double baseX, baseY, baseZ; + + private final boolean force; + private final float defaultOffsetX, defaultOffsetY, defaultOffsetZ; + private final int defaultCount; + private final float speed; + + /** + * Pre-computed NMS options for the default particle (used when no modifier changed + * particle/data). + */ + private final ParticleOptions defaultOptions; + // Converted options for particle data set by modifiers, keyed by the data object's identity. + // Modifiers such as gradients hand out the same few data objects for every point. + private final Map optionsCache = new IdentityHashMap<>(); + private org.bukkit.Particle cachedOptionsParticle; + + private List> chunk; + + // the context to use for rendering + private final ParticleRenderContext context; + + /** + * Creates a renderer instance for one draw call. + * + * @param particle the particle configuration (also stored as defaults in the context) + * @param recipients pre-filtered list of players + * @param baseLoc world-space base location for point offsets + */ + public NMSParticleRenderer(ParticleEffect particle, List recipients, Location baseLoc) { + this.baseX = baseLoc.getX(); + this.baseY = baseLoc.getY(); + this.baseZ = baseLoc.getZ(); + + this.force = particle.force(); + this.defaultOffsetX = (float) particle.offsetX(); + this.defaultOffsetY = (float) particle.offsetY(); + this.defaultOffsetZ = (float) particle.offsetZ(); + this.defaultCount = particle.count(); + this.speed = (float) particle.speedX(); + this.defaultOptions = CraftParticle.createParticleParam(particle.particle(), particle.data()); + + this.serverPlayers = new ArrayList<>(recipients.size()); + for (Player player : recipients) { + this.serverPlayers.add(((CraftPlayer) player).getHandle()); + } + + this.context = new ParticleRenderContext(particle); + } + + @Override + public ParticleRenderContext getContext() { + return context; + } + + @Override + public void begin(int totalPoints) { + chunk = new ArrayList<>(Math.min(Math.max(totalPoints, 1), MAX_BUNDLE_SIZE)); + } + + @Override + public void renderPoint(ParticleRenderContext context) { + if (!context.visible) return; + + float offsetX = defaultOffsetX; + float offsetY = defaultOffsetY; + float offsetZ = defaultOffsetZ; + int count = defaultCount; + + double px = baseX + context.x + context.displacementX; + double py = baseY + context.y + context.displacementY; + double pz = baseZ + context.z + context.displacementZ; + + // Motion override + if (context.hasMotion) { + offsetX = context.motionX; + offsetY = context.motionY; + offsetZ = context.motionZ; + count = 0; + } + + // Use default cached options if no modifier changed the particle; recompute otherwise. + ParticleOptions options = context.isDefaultParticle() + ? defaultOptions + : optionsFor(context.particle, context.data); + + ClientboundLevelParticlesPacket pkt = createPacket( + options, force, px, py, pz, offsetX, offsetY, offsetZ, speed, count + ); + // the packet could not be created; the failure has already been logged + if (pkt == null) return; + chunk.add(pkt); + + if (chunk.size() >= MAX_BUNDLE_SIZE) { + sendChunkToPlayers(chunk, serverPlayers); + chunk = new ArrayList<>(MAX_BUNDLE_SIZE); + } + } + + @Override + public void end() { + if (!chunk.isEmpty()) { + sendChunkToPlayers(chunk, serverPlayers); + } + } + + /** + * Converts a particle and its data to NMS options, reusing the result for data objects seen before. + */ + private ParticleOptions optionsFor(org.bukkit.Particle particle, @Nullable Object data) { + if (data == null) + return CraftParticle.createParticleParam(particle, null); + // the cache is keyed by data alone, so it only holds options for one particle type at a time + if (particle != cachedOptionsParticle) { + optionsCache.clear(); + cachedOptionsParticle = particle; + } + ParticleOptions options = optionsCache.get(data); + if (options == null) { + options = CraftParticle.createParticleParam(particle, data); + if (optionsCache.size() < MAX_CACHED_OPTIONS) + optionsCache.put(data, options); + } + return options; + } + + /** + * Creates a particle packet using the version-appropriate constructor. + */ + private static ClientboundLevelParticlesPacket createPacket( + ParticleOptions options, boolean force, + double x, double y, double z, + float xDist, float yDist, float zDist, float maxSpeed, int count) { + try { + assert PACKET_FACTORY != null; + return PACKET_FACTORY.create(options, force, x, y, z, xDist, yDist, zDist, maxSpeed, count); + } catch (Throwable e) { + LOGGER.log(Level.WARNING, "Failed to create particle packet", e); + return null; + } + } + + /** + * Sends a chunk of packets to all players as a single bundle each. + */ + private static void sendChunkToPlayers(List> chunk, List players) { + ClientboundBundlePacket bundle = new ClientboundBundlePacket(chunk); + for (ServerPlayer player : players) { + player.connection.send(bundle); + } + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/ParticleRenderContext.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/ParticleRenderContext.java new file mode 100644 index 0000000..82e0e0c --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/ParticleRenderContext.java @@ -0,0 +1,102 @@ +package com.sovdee.skriptparticles.rendering; + +import com.sovdee.shapes.modifiers.PointContext; +import org.jetbrains.annotations.Nullable; +import org.joml.Quaterniond; +import com.destroystokyo.paper.ParticleBuilder; + +/** + * Plugin-side per-point context extending shapes-lib {@link PointContext} with + * render-specific properties. Written by render modifiers, read by the renderer. + * + *

A single instance is allocated per draw call and reused across all points. + * {@link #reset()} clears all render fields between points. + */ +public class ParticleRenderContext extends PointContext { + + // ---- Set once per draw call ---- + + /** + * Shape orientation in world space, used by motion modifiers to transform the local Y axis. + */ + public @Nullable Quaterniond orientation; + + /** + * Shape scale (informational, available to modifiers). + */ + public double scale = 1.0; + + // ---- Written by render modifiers, read by the renderer ---- + + /** + * Particle type. + */ + public org.bukkit.Particle particle; + private final org.bukkit.Particle defaultParticle; + + /** + * Particle data. + */ + public Object data; + private final Object defaultData; + + /** + * Whether this point should be rendered at all. + */ + public boolean visible = true; + + /** + * Position displacement added to the world coordinates at render time. + */ + public double displacementX, displacementY, displacementZ; + + /** + * Directional motion override (used as particle velocity). Only applied when {@link #hasMotion} + * is true. + */ + public float motionX, motionY, motionZ; + + /** + * Whether {@link #motionX}/{@link #motionY}/{@link #motionZ} should override the default motion. + */ + public boolean hasMotion; + + public ParticleRenderContext(ParticleBuilder particle) { + this.defaultParticle = particle.particle(); + this.particle = defaultParticle; + this.defaultData = particle.data(); + this.data = defaultData; + } + + /** + * Returns {@code true} if {@link #particle} and {@link #data} are still at their defaults. + * Used by the renderer to skip re-computing NMS options when no modifier changed them. + */ + public boolean isDefaultParticle() { + return particle == defaultParticle && data == defaultData; + } + + /** + * @return the data of the particle being drawn, before any modifier changed it + */ + public @Nullable Object defaultData() { + return defaultData; + } + + /** + * Resets all render fields to their defaults before each point is processed. + */ + @Override + public void reset() { + particle = defaultParticle; + data = defaultData; + visible = true; + displacementX = 0; + displacementY = 0; + displacementZ = 0; + motionX = 0; + motionY = 0; + motionZ = 0; + hasMotion = false; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/AbstractGradientModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/AbstractGradientModifier.java new file mode 100644 index 0000000..72d74f6 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/AbstractGradientModifier.java @@ -0,0 +1,170 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.Easable; +import com.sovdee.shapes.modifiers.EasingFunction; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.Color; +import org.bukkit.Particle.DustOptions; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Base class for gradient modifiers. Manages a list of {@link ColorStop}s and provides + * a pre-built 256-entry LUT for zero-alloc per-point colour lookup. + */ +public abstract class AbstractGradientModifier implements PointModifier, Easable { + + protected final List stops = new ArrayList<>(); + protected EasingFunction easing = EasingFunction.LINEAR; + /** + * Pre-built LUT: 256 Color objects, one per 1/255 position step. + */ + protected Color[] lut; + /** + * Pre-built DustOptions LUT, parallel to {@link #lut}. Built in {@link #buildLUT()}. + */ + protected DustOptions[] dustLut; + // the dust size the dust lookup table was built for + private float dustLutSize = Float.NaN; + + protected AbstractGradientModifier() {} + + protected AbstractGradientModifier(Color from, Color to) { + stops.add(new ColorStop(0.0, from)); + stops.add(new ColorStop(1.0, to)); + } + + protected AbstractGradientModifier(List stops) { + this.stops.addAll(stops); + } + + public void addStop(ColorStop stop) { + stops.add(stop); + stops.sort(Comparator.comparingDouble(ColorStop::position)); + } + + public void addStop(double position, Color color) { + addStop(new ColorStop(position, color)); + } + + public List getStops() { + return stops; + } + + /** + * Replaces every stop with the given colours, spaced evenly from 0 to 1. A single colour makes a solid gradient. + * + * @param colors the colours, in order; ignored if empty + */ + public void setColors(List colors) { + if (colors.isEmpty()) + return; + stops.clear(); + stops.addAll(evenlySpaced(colors)); + } + + /** + * @return stops for the given colours, spaced evenly from 0 to 1; a single colour is used at both ends + */ + public static List evenlySpaced(List colors) { + List result = new ArrayList<>(Math.max(2, colors.size())); + if (colors.size() == 1) { + result.add(new ColorStop(0.0, colors.getFirst())); + result.add(new ColorStop(1.0, colors.getFirst())); + return result; + } + for (int i = 0; i < colors.size(); i++) + result.add(new ColorStop((double) i / (colors.size() - 1), colors.get(i))); + return result; + } + + /** + * Builds the 256-entry LUT from the current stops. + * Called in {@link #prepare(ShapeBounds)}. + */ + protected void buildLUT() { + stops.sort(Comparator.comparingDouble(ColorStop::position)); + lut = new Color[256]; + for (int i = 0; i < 256; i++) { + lut[i] = ColorStop.interpolate(stops, i / 255.0); + } + // built on demand, once the dust size is known + dustLut = null; + } + + /** + * Looks up the DustOptions LUT for {@code t ∈ [0, 1]}, applying this gradient's + * {@link EasingFunction} before the lookup. + */ + protected DustOptions dustLookup(double t) { + return dustLookup(t, 1.0f); + } + + /** + * Looks up the dust options for a normalised input, with the given dust size. + * The table of dust options is built on first use and reused while the size stays the same. + * + * @param t the normalised input, before easing + * @param size the size of the dust particles + */ + protected DustOptions dustLookup(double t, float size) { + if (dustLut == null || size != dustLutSize) { + if (lut == null) + buildLUT(); + DustOptions[] table = new DustOptions[256]; + for (int i = 0; i < 256; i++) { + table[i] = new DustOptions(lut[i], size); + } + dustLut = table; + dustLutSize = size; + } + int idx = (int) (easing.apply(t) * 255.0 + 0.5); + if (idx < 0) idx = 0; + if (idx > 255) idx = 255; + return dustLut[idx]; + } + + @Override + public EasingFunction getEasing() { + return easing; + } + @Override + public void setEasing(EasingFunction easing) { + this.easing = easing; + } + + @Override + public void prepare(ShapeBounds bounds) { + buildLUT(); + } + + @Override + public Class contextType() { + return ParticleRenderContext.class; + } + + @Override + public int modifierHash() { + int hash = getClass().getSimpleName().hashCode(); + for (ColorStop s : stops) { + hash = 31 * hash + Double.hashCode(s.position()); + hash = 31 * hash + s.color().hashCode(); + } + hash = 31 * hash + easing.easingHash(); + return hash; + } + + protected abstract AbstractGradientModifier createInstance(); + + @Override + public AbstractGradientModifier clone() { + AbstractGradientModifier copy = createInstance(); + copy.stops.addAll(stops); // ColorStop is a record, immutable + copy.easing = easing; // EasingFunction is immutable + return copy; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/ColorStop.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/ColorStop.java new file mode 100644 index 0000000..d9a8633 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/ColorStop.java @@ -0,0 +1,50 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import org.bukkit.Color; + +import java.util.List; + +/** + * A color at a specific position in a gradient (position in [0, 1]). + */ +public record ColorStop(double position, Color color) { + + /** + * Interpolates between a sorted list of color stops at position {@code t}. + * Clamps t to [0, 1]. Assumes stops are sorted by position ascending. + */ + public static Color interpolate(List stops, double t) { + if (stops.isEmpty()) return Color.WHITE; + if (stops.size() == 1) return stops.getFirst().color(); + + t = Math.max(0.0, Math.min(1.0, t)); + + // Find surrounding stops + ColorStop prev = stops.getFirst(); + // before the first stop there is nothing to blend from + if (t <= prev.position()) return prev.color(); + for (int i = 1; i < stops.size(); i++) { + ColorStop next = stops.get(i); + if (t <= next.position()) { + double range = next.position() - prev.position(); + if (range < 1e-12) return prev.color(); + double localT = (t - prev.position()) / range; + return lerp(prev.color(), next.color(), localT); + } + prev = next; + } + // t is past the last stop + return stops.getLast().color(); + } + + /** + * Linearly interpolates between two colors. + */ + private static Color lerp(Color a, Color b, double t) { + return Color.fromRGB( + (int) (a.getRed() + t * (b.getRed() - a.getRed())), + (int) (a.getGreen() + t * (b.getGreen() - a.getGreen())), + (int) (a.getBlue() + t * (b.getBlue() - a.getBlue())) + ); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifier.java new file mode 100644 index 0000000..551907f --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifier.java @@ -0,0 +1,84 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; + +import java.util.ArrayList; +import java.util.List; + +/** + * Chains multiple {@link PointModifier}s. Each modifier runs on the same {@link PointContext} + * in order — later modifiers see and can further modify earlier modifiers' output. + * No temporary objects are allocated; all modifiers share the same {@code PointContext}. + */ +public class CompositeModifier implements PointModifier { + + private final List> modifiers; + + public CompositeModifier() { + this.modifiers = new ArrayList<>(); + } + + public CompositeModifier(List> modifiers) { + this.modifiers = new ArrayList<>(modifiers); + } + + @Override + public void prepare(ShapeBounds bounds) { + for (PointModifier modifier : modifiers) { + modifier.prepare(bounds); + } + } + + @Override + public void modify(Context point) { + for (PointModifier modifier : modifiers) { + modifier.modify(point); + } + } + + @Override + public Class contextType() { + Class result = PointContext.class; + for (PointModifier mod : modifiers) { + Class t = mod.contextType(); + if (result.isAssignableFrom(t)) result = t; + } + return result; + } + + @Override + public int modifierHash() { + int hash = 1; + for (PointModifier mod : modifiers) { + hash = 31 * hash + mod.modifierHash(); + } + return hash; + } + + public void addModifier(PointModifier modifier) { + modifiers.add(modifier); + } + + public boolean removeModifier(PointModifier modifier) { + return modifiers.remove(modifier); + } + + public List> getModifiers() { + return modifiers; + } + + public boolean isEmpty() { + return modifiers.isEmpty(); + } + + @Override + public CompositeModifier clone() { + List> cloned = new ArrayList<>(modifiers.size()); + for (PointModifier m : modifiers) { + cloned.add(m.clone()); + } + return new CompositeModifier<>(cloned); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CustomSkriptModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CustomSkriptModifier.java new file mode 100644 index 0000000..bed6f6f --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/CustomSkriptModifier.java @@ -0,0 +1,119 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import ch.njol.skript.lang.Trigger; +import ch.njol.skript.lang.TriggerItem; +import ch.njol.skript.variables.Variables; +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A render modifier whose behaviour is defined by a user-written Skript section. + * The section body runs once per point, on the render thread (async by default). + *

+ * Local variables from the creating event are snapshotted when the modifier is built and + * installed onto the point event once per render pass ({@link #prepare}). The body sees and may + * mutate those locals, and mutations persist from point to point within a pass; each new pass + * starts fresh from the snapshot. Writes never propagate back to the creating event's locals. + */ +public class CustomSkriptModifier implements PointModifier { + + private final Trigger trigger; + /** + * Owns the creation-time locals snapshot (locals are keyed by event instance in Skript). + * Read-only after construction and shared by clones. + */ + private final @Nullable PointModifierEvent localsHolder; + private final ThreadLocal threadEvent = ThreadLocal.withInitial(PointModifierEvent::new); + /** + * Incremented each pass; batches of one pass may hop scheduler threads, so each thread's + * event installs the locals snapshot lazily on its first point of the pass. + */ + private volatile int passId = 0; + + public CustomSkriptModifier(Trigger trigger, @Nullable Object localsSnapshot) { + this.trigger = trigger; + if (localsSnapshot != null) { + this.localsHolder = new PointModifierEvent(); + Variables.setLocalVariables(localsHolder, localsSnapshot); + } else { + this.localsHolder = null; + } + } + + private CustomSkriptModifier(Trigger trigger, @Nullable PointModifierEvent localsHolder) { + this.trigger = trigger; + this.localsHolder = localsHolder; + } + + @Override + public void prepare(ShapeBounds bounds) { + passId++; + } + + @Override + public void modify(ParticleRenderContext point) { + var event = threadEvent.get(); + if (event.passId != passId) { + // first point of this pass on this thread: replace leftovers with a fresh snapshot copy + event.passId = passId; + Variables.removeLocals(event); + if (localsHolder != null) { + Object copy = Variables.copyLocalVariables(localsHolder); + if (copy != null) + Variables.setLocalVariables(event, copy); + } + } + event.setPoint(point); // reuse event to avoid extra allocation + // walk instead of Trigger#execute: execute would remove the pass-shared locals after every point + TriggerItem.walk(trigger, event); + } + + @Override + public Class contextType() { + return ParticleRenderContext.class; + } + + @Override + public int modifierHash() { + return System.identityHashCode(trigger); + } + + @Override + public CustomSkriptModifier clone() { + return new CustomSkriptModifier(trigger, localsHolder); + } + + @Override + public String toString() { + return "custom point modifier (" + trigger.getName() + ")"; + } + + public static class PointModifierEvent extends Event { + + private ParticleRenderContext point; + private int passId = -1; + + public PointModifierEvent() { + this.point = null; + } + + public ParticleRenderContext getPoint() { + return point; + } + + public void setPoint(ParticleRenderContext point) { + this.point = point; + } + + @Override + public @NotNull HandlerList getHandlers() { + throw new IllegalStateException("Should not be listened to!"); + } + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifier.java new file mode 100644 index 0000000..afe1d4c --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifier.java @@ -0,0 +1,63 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.HasInput; +import com.sovdee.shapes.modifiers.NormalizedInput; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.Color; +import org.bukkit.Particle; +import org.bukkit.Particle.DustOptions; + +import java.util.List; + +/** + * Colors each point by sampling a {@link NormalizedInput} and looking up the result + * in a pre-built colour gradient LUT. + */ +public class GradientModifier extends AbstractGradientModifier implements HasInput { + + private NormalizedInput input; + + public GradientModifier(Color from, Color to, NormalizedInput input) { + super(from, to); + this.input = input; + } + + public GradientModifier(List stops, NormalizedInput input) { + super(stops); + this.input = input; + } + + @Override + public void modify(ParticleRenderContext point) { + // colouring needs dust particles, but keep the size if the shape was already using dust + float size = point.defaultData() instanceof DustOptions dust ? dust.getSize() : 1.0f; + point.particle = Particle.DUST; + point.data = dustLookup(input.sample(point), size); + } + + @Override + public NormalizedInput getInput() { + return input; + } + + @Override + public void setInput(NormalizedInput input) { + this.input = input; + } + + @Override + public int modifierHash() { + int hash = input.inputHash(); + for (ColorStop s : stops) { + hash = 31 * hash + Double.hashCode(s.position()); + hash = 31 * hash + s.color().hashCode(); + } + hash = 31 * hash + easing.easingHash(); + return hash; + } + + @Override + protected AbstractGradientModifier createInstance() { + return new GradientModifier(List.of(), input); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifier.java new file mode 100644 index 0000000..ffaac40 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifier.java @@ -0,0 +1,194 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.joml.Quaterniond; +import org.joml.Vector3d; + +/** + * Applies directional particle velocity based on the point's position relative to the shape. + * Replaces the removed {@code ParticleMotion} enum. + */ +public class MotionModifier implements PointModifier { + + public enum Mode { + /** + * Counterclockwise orbit around the shape's Y axis. + */ + COUNTERCLOCKWISE, + /** + * Clockwise orbit around the shape's Y axis. + */ + CLOCKWISE, + /** + * Velocity directed toward the origin. + */ + INWARDS, + /** + * Velocity directed away from the origin. + */ + OUTWARDS, + /** + * No velocity (zero motion). + */ + NONE, + /** + * The same velocity for every point, in world space. + */ + GLOBAL, + /** + * A velocity relative to each point's place around the shape: x outwards from the shape's y axis, + * y along the shape's y axis, and z around it (positive is counterclockwise, like {@link #COUNTERCLOCKWISE}). + */ + RELATIVE + } + + private final Mode mode; + // the velocity for GLOBAL and RELATIVE; unused by the other modes + private final Vector3d velocity = new Vector3d(); + /** + * Y axis in world space after orientation transform, cached in prepare(). + */ + private float yAxisX, yAxisY, yAxisZ; + private boolean yAxisComputed = false; + + public MotionModifier(Mode mode) { + this.mode = mode; + } + + /** + * @param mode {@link Mode#GLOBAL} or {@link Mode#RELATIVE} + * @param velocity the velocity; its length is the speed, multiplied by the particle's speed when drawn + */ + public MotionModifier(Mode mode, Vector3d velocity) { + this.mode = mode; + this.velocity.set(velocity); + } + + @Override + public void prepare(ShapeBounds bounds) { + // Reset so we re-compute on first ParticleRenderContext seen + yAxisComputed = false; + } + + @Override + public Class contextType() { + return ParticleRenderContext.class; + } + + @Override + public void modify(ParticleRenderContext rc) { + if (mode == Mode.NONE) return; + + // Lazily compute Y axis from orientation on first point + if (!yAxisComputed) { + if (rc.orientation != null) { + Quaterniond q = rc.orientation; + // float cast for the transform + org.joml.Quaternionf qf = new org.joml.Quaternionf( + (float) q.x, (float) q.y, (float) q.z, (float) q.w); + Vector3d y = qf.transform(new Vector3d(0, 1, 0)); + yAxisX = (float) y.x; + yAxisY = (float) y.y; + yAxisZ = (float) y.z; + } else { + yAxisX = 0; yAxisY = 1; yAxisZ = 0; + } + yAxisComputed = true; + } + + float px = (float) rc.x; + float py = (float) rc.y; + float pz = (float) rc.z; + + float mx, my, mz; + switch (mode) { + case COUNTERCLOCKWISE -> { + // cross(yAxis, point) + mx = yAxisY * pz - yAxisZ * py; + my = yAxisZ * px - yAxisX * pz; + mz = yAxisX * py - yAxisY * px; + float len = (float) Math.sqrt(mx * mx + my * my + mz * mz); + if (len > 1e-6f) { mx /= len; my /= len; mz /= len; } + } + case CLOCKWISE -> { + // -cross(yAxis, point) + mx = -(yAxisY * pz - yAxisZ * py); + my = -(yAxisZ * px - yAxisX * pz); + mz = -(yAxisX * py - yAxisY * px); + float len = (float) Math.sqrt(mx * mx + my * my + mz * mz); + if (len > 1e-6f) { mx /= len; my /= len; mz /= len; } + } + case OUTWARDS -> { + float len = (float) Math.sqrt(px * px + py * py + pz * pz); + if (len > 1e-6f) { mx = px / len; my = py / len; mz = pz / len; } + else { mx = my = mz = 0; } + } + case INWARDS -> { + float len = (float) Math.sqrt(px * px + py * py + pz * pz); + if (len > 1e-6f) { mx = -px / len; my = -py / len; mz = -pz / len; } + else { mx = my = mz = 0; } + } + case GLOBAL -> { + mx = (float) velocity.x; + my = (float) velocity.y; + mz = (float) velocity.z; + } + case RELATIVE -> { + // outwards: the point with its part along the y axis removed + float along = px * yAxisX + py * yAxisY + pz * yAxisZ; + float ox = px - along * yAxisX, oy = py - along * yAxisY, oz = pz - along * yAxisZ; + float len = (float) Math.sqrt(ox * ox + oy * oy + oz * oz); + if (len > 1e-6f) { ox /= len; oy /= len; oz /= len; } + else { ox = oy = oz = 0; } + // around: cross(yAxis, outwards), the counterclockwise direction + float ax = yAxisY * oz - yAxisZ * oy; + float ay = yAxisZ * ox - yAxisX * oz; + float az = yAxisX * oy - yAxisY * ox; + float vx = (float) velocity.x, vy = (float) velocity.y, vz = (float) velocity.z; + mx = vx * ox + vy * yAxisX + vz * ax; + my = vx * oy + vy * yAxisY + vz * ay; + mz = vx * oz + vy * yAxisZ + vz * az; + } + default -> { return; } + } + + rc.motionX = mx; + rc.motionY = my; + rc.motionZ = mz; + rc.hasMotion = true; + } + + @Override + public int modifierHash() { + return 31 * mode.ordinal() + velocity.hashCode(); + } + + public Mode getMode() { + return mode; + } + + /** + * @return whether this modifier's motion comes from {@link #getVelocity()} + */ + public boolean hasVelocity() { + return mode == Mode.GLOBAL || mode == Mode.RELATIVE; + } + + /** + * @return a copy of the velocity used by {@link Mode#GLOBAL} and {@link Mode#RELATIVE} + */ + public Vector3d getVelocity() { + return new Vector3d(velocity); + } + + public void setVelocity(Vector3d velocity) { + this.velocity.set(velocity); + } + + @Override + public MotionModifier clone() { + return new MotionModifier(mode, velocity); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawData.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawData.java deleted file mode 100644 index 7362f9e..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawData.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.sovdee.skriptparticles.shapes; - -import com.sovdee.shapes.sampling.DrawContext; -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.particles.Particle; -import com.sovdee.skriptparticles.util.DynamicLocation; -import com.sovdee.skriptparticles.util.Quaternion; -import org.checkerframework.checker.nullness.qual.Nullable; - -/** - * Plugin-side rendering metadata attached to library shapes via {@link DrawContext}. - * Holds particle, location, animation, and debug axis state. - */ -public class DrawData implements DrawContext { - - private Particle particle; - private @Nullable DynamicLocation location; - private @Nullable DynamicLocation lastLocation; - private final Quaternion lastOrientation; - private long animationDuration = 0; - private boolean drawLocalAxes = false; - private boolean drawGlobalAxes = false; - - public DrawData() { - this.particle = (Particle) new Particle(org.bukkit.Particle.FLAME).extra(0); - this.lastOrientation = Quaternion.IDENTITY.clone(); - } - - /** - * Gets the DrawData attached to a shape's PointSampler, creating and attaching one if missing. - */ - public static DrawData of(Shape shape) { - DrawContext ctx = shape.getPointSampler().getDrawContext(); - if (ctx instanceof DrawData dd) return dd; - DrawData dd = new DrawData(); - shape.getPointSampler().setDrawContext(dd); - return dd; - } - - // ---- Particle ---- - - public Particle getParticle() { return particle.clone(); } - - public Particle getParticleRaw() { return particle; } - - public void setParticle(Particle particle) { this.particle = particle; } - - // ---- Location ---- - - @Nullable - public DynamicLocation getLocation() { - if (location == null) return null; - return location.clone(); - } - - public void setLocation(DynamicLocation location) { this.location = location; } - - @Nullable - public DynamicLocation getLastLocation() { return lastLocation; } - - public void setLastLocation(@Nullable DynamicLocation lastLocation) { this.lastLocation = lastLocation; } - - // ---- Orientation ---- - - public Quaternion getLastOrientation() { return lastOrientation; } - - public void setLastOrientation(Quaternion orientation) { this.lastOrientation.set(orientation); } - - // ---- Animation ---- - - public long getAnimationDuration() { return animationDuration; } - - public void setAnimationDuration(long animationDuration) { this.animationDuration = animationDuration; } - - // ---- Axes ---- - - public boolean showLocalAxes() { return drawLocalAxes; } - - public void showLocalAxes(boolean show) { this.drawLocalAxes = show; } - - public boolean showGlobalAxes() { return drawGlobalAxes; } - - public void showGlobalAxes(boolean show) { this.drawGlobalAxes = show; } - - // ---- DrawContext ---- - - @Override - public DrawData copy() { - DrawData copy = new DrawData(); - copy.particle = this.particle.clone(); - if (this.location != null) - copy.location = this.location.clone(); - if (this.lastLocation != null) - copy.lastLocation = this.lastLocation.clone(); - copy.lastOrientation.set(this.lastOrientation); - copy.animationDuration = this.animationDuration; - copy.drawLocalAxes = this.drawLocalAxes; - copy.drawGlobalAxes = this.drawGlobalAxes; - return copy; - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawManager.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawManager.java deleted file mode 100644 index 7e349e4..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/DrawManager.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.sovdee.skriptparticles.shapes; - -import ch.njol.skript.Skript; -import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.particles.Particle; -import com.sovdee.skriptparticles.particles.ParticleGradient; -import com.sovdee.skriptparticles.util.DynamicLocation; -import com.sovdee.skriptparticles.util.MathUtil; -import com.sovdee.skriptparticles.util.ParticleUtil; -import com.sovdee.skriptparticles.util.Quaternion; -import com.sovdee.skriptparticles.util.VectorConversion; -import org.bukkit.entity.Player; -import org.bukkit.scheduler.BukkitRunnable; -import org.bukkit.util.Vector; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.joml.Quaterniond; -import org.joml.Vector3d; - -import java.util.Collection; -import java.util.Iterator; -import java.util.List; -import java.util.Set; -import java.util.function.Consumer; - -/** - * Static draw methods for rendering library shapes with plugin DrawData. - */ -public class DrawManager { - - public static void draw(Shape shape, Collection recipients) { - DrawData dd = DrawData.of(shape); - DynamicLocation location = dd.getLocation(); - if (location == null) return; - draw(shape, location, Quaternion.IDENTITY, dd.getParticleRaw(), recipients); - } - - public static void draw(Shape shape, DynamicLocation location, Collection recipients) { - draw(shape, location, Quaternion.IDENTITY, DrawData.of(shape).getParticleRaw(), recipients); - } - - public static void drawWithConsumer(Shape shape, DynamicLocation location, Consumer consumer, Collection recipients) { - consumer.accept(shape); - DrawData dd = DrawData.of(shape); - Quaterniond shapeOrientation = shape.getOrientation(); - Quaternion shapeOrientationQ = new Quaternion((float) shapeOrientation.x, (float) shapeOrientation.y, (float) shapeOrientation.z, (float) shapeOrientation.w); - draw(shape, location, shapeOrientationQ, dd.getParticleRaw(), recipients); - } - - public static void draw(Shape shape, DynamicLocation location, Quaternion baseOrientation, Particle particle, Collection recipients) { - DrawData dd = DrawData.of(shape); - - if (location.isNull()) { - DynamicLocation shapeLocation = dd.getLocation(); - if (shapeLocation == null) return; - location = shapeLocation.clone(); - } - - dd.setLastLocation(location.clone()); - Quaterniond shapeOrientation = shape.getOrientation(); - Quaternion shapeOrientationQ = new Quaternion((float) shapeOrientation.x, (float) shapeOrientation.y, (float) shapeOrientation.z, (float) shapeOrientation.w); - dd.getLastOrientation().set(baseOrientation.clone().mul(shapeOrientationQ)); - - if (!particle.override()) { - dd.getParticleRaw().parent(shape); - particle = dd.getParticleRaw(); - @Nullable ParticleGradient gradient = particle.gradient(); - if (gradient != null && gradient.isLocal()) - gradient.setOrientation(dd.getLastOrientation()); - } - - particle.receivers(recipients); - - // Get points from library shape using the last orientation - Quaterniond lastOrientationD = new Quaterniond(dd.getLastOrientation().x, dd.getLastOrientation().y, dd.getLastOrientation().z, dd.getLastOrientation().w); - Set jomlPoints = shape.getPointSampler().getPoints(shape, lastOrientationD); - Collection toDraw = VectorConversion.toBukkit(jomlPoints); - - long animationDuration = dd.getAnimationDuration(); - if (animationDuration > 0) { - int particleCount = toDraw.size(); - double millisecondsPerPoint = animationDuration / (double) particleCount; - Iterator> batchIterator = MathUtil.batch(toDraw, millisecondsPerPoint).iterator(); - Particle finalParticle = particle; - BukkitRunnable runnable = new BukkitRunnable() { - @Override - public void run() { - if (!batchIterator.hasNext()) { - this.cancel(); - return; - } - List batch = batchIterator.next(); - try { - for (Vector point : batch) { - finalParticle.spawn(point); - } - } catch (IllegalArgumentException e) { - Skript.error("Failed to spawn particle! Error: " + e.getMessage()); - } - } - }; - runnable.runTaskTimerAsynchronously(Skript.getInstance(), 0, 1); - } else { - for (Vector point : toDraw) { - try { - particle.spawn(point); - } catch (IllegalArgumentException e) { - Skript.error("Failed to spawn particle! Error: " + e.getMessage()); - return; - } - } - } - - if (dd.showLocalAxes()) { - ParticleUtil.drawAxes(location.getLocation().add(VectorConversion.toBukkit(shape.getOffset())), dd.getLastOrientation(), recipients); - } - if (dd.showGlobalAxes()) { - ParticleUtil.drawAxes(location.getLocation().add(VectorConversion.toBukkit(shape.getOffset())), Quaternion.IDENTITY, recipients); - } - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/package-info.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/package-info.java deleted file mode 100644 index e03d983..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/shapes/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -@DefaultQualifier(NonNull.class) -package com.sovdee.skriptparticles.shapes; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.framework.qual.DefaultQualifier; \ No newline at end of file diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/SkriptParticleModule.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/SkriptParticleModule.java new file mode 100644 index 0000000..33c4025 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/SkriptParticleModule.java @@ -0,0 +1,40 @@ +package com.sovdee.skriptparticles.skript; + +import com.sovdee.skriptparticles.skript.drawing.DrawingModule; +import com.sovdee.skriptparticles.skript.shaders.ShadersModule; +import com.sovdee.skriptparticles.skript.shapes.ShapesModule; +import org.skriptlang.skript.addon.AddonModule; +import org.skriptlang.skript.addon.HierarchicalAddonModule; +import org.skriptlang.skript.addon.SkriptAddon; + +import java.util.List; + +/** + * Holds all Skript syntax for skript-particle. + */ +public class SkriptParticleModule extends HierarchicalAddonModule { + + public SkriptParticleModule() { + super(); + } + + @Override + protected void loadSelf(SkriptAddon addon) { + // all syntax lives in the child modules + } + + @Override + public Iterable children() { + return List.of( + new ShapesModule(this), + new DrawingModule(this), + new ShadersModule(this) + ); + } + + @Override + public String name() { + return "skript-particle"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/DrawingModule.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/DrawingModule.java new file mode 100644 index 0000000..305403c --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/DrawingModule.java @@ -0,0 +1,41 @@ +package com.sovdee.skriptparticles.skript.drawing; + +import ch.njol.skript.registrations.EventValues; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.skript.drawing.expressions.ExprDrawnShapes; +import com.sovdee.skriptparticles.skript.drawing.sections.DrawShapeEffectSection.DrawEvent; +import com.sovdee.skriptparticles.skript.drawing.sections.EffSecDrawShape; +import com.sovdee.skriptparticles.skript.drawing.sections.EffSecDrawShapeAnimation; +import org.skriptlang.skript.addon.AddonModule; +import org.skriptlang.skript.addon.HierarchicalAddonModule; +import org.skriptlang.skript.addon.SkriptAddon; + +/** + * Holds syntax related to drawing shapes with particles. + */ +public class DrawingModule extends HierarchicalAddonModule { + + public DrawingModule(AddonModule parentModule) { + super(parentModule); + } + + @Override + protected void initSelf(SkriptAddon addon) { + EventValues.registerEventValue(DrawEvent.class, Shape.class, DrawEvent::getShape, EventValues.TIME_NOW); + } + + @Override + protected void loadSelf(SkriptAddon addon) { + register(addon, + EffSecDrawShape::register, + EffSecDrawShapeAnimation::register, + ExprDrawnShapes::register + ); + } + + @Override + public String name() { + return "drawing"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/expressions/ExprDrawnShapes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/expressions/ExprDrawnShapes.java new file mode 100644 index 0000000..83b5b5c --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/expressions/ExprDrawnShapes.java @@ -0,0 +1,40 @@ +package com.sovdee.skriptparticles.skript.drawing.expressions; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.EventValueExpression; +import com.sovdee.shapes.shapes.Shape; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Drawn/Current Shape") +@Description("Returns the shape that is being drawn by the draw section.") +@Example(""" + draw the shapes {_shapes::*} at player's head: + # only affects the drawn version, the original shape is not changed + set radius of drawn shape to 2 + """) +@Since("1.0.0") +public class ExprDrawnShapes extends EventValueExpression { + + public static void register(SyntaxRegistry registry) { + registry.register( + SyntaxRegistry.EXPRESSION, + infoBuilder(ExprDrawnShapes.class, Shape.class, "drawn shape") + .supplier(ExprDrawnShapes::new) + .build()); + } + + public ExprDrawnShapes() { + super(Shape.class); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "the drawn shape"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/DrawShapeEffectSection.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/DrawShapeEffectSection.java similarity index 63% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/DrawShapeEffectSection.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/DrawShapeEffectSection.java index 370bb28..8d45307 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/DrawShapeEffectSection.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/DrawShapeEffectSection.java @@ -1,4 +1,4 @@ -package com.sovdee.skriptparticles.elements.sections; +package com.sovdee.skriptparticles.skript.drawing.sections; import ch.njol.skript.Skript; import ch.njol.skript.config.SectionNode; @@ -8,7 +8,6 @@ import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.skript.lang.Trigger; import ch.njol.skript.lang.TriggerItem; -import ch.njol.skript.registrations.EventValues; import ch.njol.skript.util.Direction; import ch.njol.skript.util.Timespan; import ch.njol.skript.util.Timespan.TimePeriod; @@ -16,7 +15,7 @@ import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.Shape; import com.sovdee.skriptparticles.SkriptParticle; -import com.sovdee.skriptparticles.shapes.DrawManager; +import com.sovdee.skriptparticles.rendering.DrawManager; import com.sovdee.skriptparticles.util.DynamicLocation; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -25,8 +24,8 @@ import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import org.bukkit.scheduler.BukkitRunnable; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; @@ -38,25 +37,20 @@ public abstract class DrawShapeEffectSection extends EffectSection { public static final Timespan ONE_TICK = new Timespan(TimePeriod.TICK, 1); - static { - EventValues.registerEventValue(DrawEvent.class, Shape.class, DrawEvent::getShape, EventValues.TIME_NOW); - } - protected Expression shapes; - @Nullable - protected Expression directions; - @Nullable - protected Expression locations; - @Nullable - protected Expression players; - @Nullable - private Trigger trigger; + protected @Nullable Expression directions; + protected @Nullable Expression locations; + protected @Nullable Expression players; + private @Nullable Trigger trigger; protected boolean useShapeLocation; protected boolean sync; @Override - public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, @Nullable SectionNode sectionNode, @Nullable List list) { + public boolean init( + Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, + @Nullable SectionNode sectionNode, @Nullable List list + ) { if (hasSection()) { AtomicBoolean delayed = new AtomicBoolean(false); Runnable afterLoading = () -> delayed.set(!getParser().getHasDelayBefore().isFalse()); @@ -70,7 +64,8 @@ public boolean init(Expression[] expressions, int matchedPattern, Kleenean is return init(expressions, matchedPattern, isDelayed, parseResult, hasSection()); } - public boolean init(@Nullable Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, boolean hasSection) { + @SuppressWarnings("unchecked") + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, boolean hasSection) { shapes = (Expression) expressions[0]; if (expressions[2] != null) { @@ -94,6 +89,7 @@ protected TriggerItem walk(Event event) { Delay.addDelayedEvent(event); + // determine recipients Collection recipients = new ArrayList<>(); if (players != null) { recipients.addAll(List.of(players.getArray(event))); @@ -101,8 +97,7 @@ protected TriggerItem walk(Event event) { recipients.addAll(Bukkit.getOnlinePlayers()); } - @Nullable Object localVars = Variables.copyLocalVariables(event); - + // set up consumer to do the pre-drawing execution @Nullable Consumer consumer; if (trigger != null) { consumer = shape -> { @@ -113,6 +108,7 @@ protected TriggerItem walk(Event event) { consumer = null; } + // determine locations to draw at List locations = new ArrayList<>(); @Nullable Direction direction = null; if (!useShapeLocation) { @@ -130,20 +126,22 @@ protected TriggerItem walk(Event event) { locations.add(new DynamicLocation()); } + // Prepare the shapes: each is copied, so the section's code and the drawing never affect the original, + // and the section's code runs once per shape, on this thread, whether drawing is sync or async. + List preppedShapes = new ArrayList<>(); + Shape preppedShape; + for (Shape shape : shapes.getArray(event)) { + preppedShape = shape.clone(); + if (consumer != null) + consumer.accept(preppedShape); + preppedShapes.add(preppedShape); + } + if (preppedShapes.isEmpty()) return getNext(); + + // draw the shapes if (sync) { - executeSync(event, locations, consumer, recipients); + execute(locations, preppedShapes, recipients); } else { - List preppedShapes = new ArrayList<>(); - Shape preppedShape; - for (Shape shape : shapes.getArray(event)) { - preppedShape = shape.clone(); - if (consumer != null) - consumer.accept(preppedShape); - preppedShapes.add(preppedShape); - } - - if (preppedShapes.isEmpty()) return getNext(); - setupAsync(event, locations, preppedShapes, recipients); } return getNext(); @@ -153,41 +151,25 @@ protected void setupAsync(Event event, Collection locations, Co BukkitRunnable runnable = new BukkitRunnable() { @Override public void run() { - executeAsync(locations, shapes, recipients); + execute(locations, shapes, recipients); } }; - runnable.runTaskAsynchronously(Skript.getInstance()); - } - - protected void executeSync(Event event, Collection locations, @Nullable Consumer consumer, Collection recipients) { - try { - for (DynamicLocation dynamicLocation : locations) { - for (Shape shape : shapes.getArray(event)) { - Shape clone = shape.clone(); - if (consumer != null) { - DrawManager.drawWithConsumer(clone, dynamicLocation, consumer, recipients); - } else { - DrawManager.draw(clone, dynamicLocation, recipients); - } - } - } - } catch (IllegalArgumentException exception) { - SkriptParticle.severe("Unable to draw shape[s]! Please check that your particles are valid!"); - SkriptParticle.severe("Exception: " + exception.getMessage()); - SkriptParticle.severe("To see the full stack trace, set Skript's verbosity to very high or debug."); - if (Skript.logVeryHigh()) - exception.printStackTrace(); - } + runnable.runTaskAsynchronously(SkriptParticle.getInstance()); } - protected void executeAsync(Collection locations, Collection shapes, Collection recipients) { + protected void execute(Collection locations, Collection shapes, Collection recipients) { try { - for (DynamicLocation dynamicLocation : locations) { - for (Shape shape : shapes) { + for (Shape shape : shapes) { + // Sample the prepared shape first, so that the copies below start with its cached points: + // the points are then only generated once, however many locations or iterations are drawn. + shape.getPointSampler().getPoints(shape); + for (DynamicLocation dynamicLocation : locations) { + // each draw gets its own copy, so that draws which overlap in time (animations, + // or iterations that take longer than their period) do not share modifier state DrawManager.draw(shape.clone(), dynamicLocation, recipients); } } - } catch (IllegalArgumentException exception) { + } catch (RuntimeException exception) { SkriptParticle.severe("Unable to draw shape[s]! Please check that your particles are valid!"); SkriptParticle.severe("Exception: " + exception.getMessage()); SkriptParticle.severe("To see the full stack trace, set Skript's verbosity to very high or debug."); @@ -208,7 +190,7 @@ public Shape getShape() { } @Override - @NonNull + @NotNull public HandlerList getHandlers() { throw new IllegalStateException(); } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShape.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShape.java similarity index 66% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShape.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShape.java index c3afaba..af5f5ea 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShape.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShape.java @@ -1,23 +1,25 @@ -package com.sovdee.skriptparticles.elements.sections; +package com.sovdee.skriptparticles.skript.drawing.sections; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.registrations.EventValues; import ch.njol.skript.util.Timespan; import ch.njol.skript.util.Timespan.TimePeriod; import ch.njol.util.Kleenean; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.SkriptParticle; import com.sovdee.skriptparticles.util.DynamicLocation; import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.bukkit.scheduler.BukkitRunnable; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.concurrent.atomic.AtomicLong; @@ -39,35 +41,37 @@ "", "Drawing a shape for a duration is async only." }) -@Examples({ - "draw the shape (sphere with radius 1) at player's location", - "draw the shapes of (a sphere with radius 1 and a cube with radius 1) at player's location for (all players in radius 10 of player)", - "synchronously draw the shape of a sphere with radius 1 at player's location", - "", - "draw the shape {_shape} at player's location:", - "\tset event-shape's particle to red dust", - "", - "synchronously draw shape (a sphere with radius 1 and a cube with radius 1) at player's location:", - "\tset event-shape's radius to 2", -}) +@Example("draw the shape (sphere with radius 1) at player's location") +@Example("draw the shapes of (a sphere with radius 1 and a circle with radius 2) at player's location to (all players in radius 10 around player)") +@Example("synchronously draw the shape of a sphere with radius 1 at player's location") +@Example("draw the shape of a circle with radius 3 at player for 5 seconds with refresh rate of 2 ticks") +@Example(""" + draw the shape {_shape} at player's location: + set event-shape's particle to red dust particle + """) +@Example(""" + synchronously draw the shapes {_shapes::*} at player's location: + set event-shape's radius to 2 + """) @Since("1.0.0") public class EffSecDrawShape extends DrawShapeEffectSection { - static { - Skript.registerSection(EffSecDrawShape.class, - "[sync:sync[hronously]] draw [the] shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%]", - "draw [the] shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%] (duration:for) [duration] %timespan% [with (delay|refresh [rate]) [of] %-timespan%]" - ); - EventValues.registerEventValue(EffSecDrawShape.DrawEvent.class, Shape.class, DrawEvent::getShape, EventValues.TIME_NOW); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.SECTION, SyntaxInfo.builder(EffSecDrawShape.class) + .supplier(EffSecDrawShape::new) + .addPatterns( + "[sync:sync[hronously]] draw [the] shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%]", + "draw [the] shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%] (duration:for) [duration] %timespan% [with (delay|refresh [rate]) [of] %-timespan%]" + ) + .build()); } - @Nullable - private Expression duration; - @Nullable - private Expression delay; + private @Nullable Expression duration; + private @Nullable Expression delay; @Override - public boolean init(@Nullable Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, boolean hasSection) { + @SuppressWarnings("unchecked") + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, boolean hasSection) { if (parseResult.hasTag("duration")) { duration = (Expression) expressions[4]; delay = (Expression) expressions[5]; @@ -93,17 +97,17 @@ protected void setupAsync(Event event, Collection locations, Co BukkitRunnable runnable = new BukkitRunnable() { @Override public void run() { - executeAsync(locations, shapes, recipients); + execute(locations, shapes, recipients); if (currentIteration.incrementAndGet() >= iterations) { this.cancel(); } } }; - runnable.runTaskTimerAsynchronously(Skript.getInstance(), 0, period); + runnable.runTaskTimerAsynchronously(SkriptParticle.getInstance(), 0, period); } @Override - @NonNull + @NotNull public String toString(@Nullable Event event, boolean b) { return "draw shape " + shapes.toString(event, b) + (locations != null ? " at " + locations.toString(event, b) : "") + " for " + (players == null ? "all players" : players.toString(event, b)); } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShapeAnimation.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShapeAnimation.java similarity index 52% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShapeAnimation.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShapeAnimation.java index d65dbf1..f3e07e1 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/sections/EffSecDrawShapeAnimation.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/drawing/sections/EffSecDrawShapeAnimation.java @@ -1,8 +1,7 @@ -package com.sovdee.skriptparticles.elements.sections; +package com.sovdee.skriptparticles.skript.drawing.sections; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; @@ -10,47 +9,51 @@ import ch.njol.skript.util.Timespan; import ch.njol.skript.util.Timespan.TimePeriod; import ch.njol.util.Kleenean; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.DynamicLocation; import org.bukkit.entity.Player; import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collection; -import java.util.function.Consumer; @Name("Draw Shape Animation") @Description({ + "Draws the given shapes a few points at a time, spread evenly over the given time span, so the shape appears to be traced out.", + "Points are drawn in the shape's animation order (see Shape Draw Order). Each point is drawn once; " + + "the shape does not stay visible after the animation unless you draw it again.", + "As with the draw shape section, the code inside the section runs once for each shape before drawing begins, " + + "and changes to `drawn shape` only affect this drawing. Animations are always drawn asynchronously, " + + "so the code after this section runs right away, before the animation is finished." }) -@Examples({ -}) +@Example("draw an animation of the shape of a circle with radius 3 at player over 2 seconds") +@Example(""" + draw an animated shape of {_helix} at player to all players over 1 second: + set particle of drawn shape to red dust particle + """) @Since("1.2.0") public class EffSecDrawShapeAnimation extends DrawShapeEffectSection { - static { - Skript.registerSection(EffSecDrawShapeAnimation.class, - "draw [an] (animation [of] [the]|animated) shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%] over %timespan%" - ); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.SECTION, SyntaxInfo.builder(EffSecDrawShapeAnimation.class) + .supplier(EffSecDrawShapeAnimation::new) + .addPatterns("draw [an] (animation [of] [the]|animated) shape[s] [of] %shapes% [%-directions% %-locations/entities%] [to %-players%] over %timespan%") + .build()); } private Expression duration; @Override + @SuppressWarnings("unchecked") public boolean init(@Nullable Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult, boolean hasSection) { duration = (Expression) expressions[4]; return super.init(expressions, matchedPattern, isDelayed, parseResult, hasSection); } - /** - * This method should not be called for this section. - */ - @Override - protected void executeSync(Event event, Collection locations, @Nullable Consumer consumer, Collection recipients) { - // intentionally empty - } - @Override protected void setupAsync(Event event, Collection locations, Collection shapes, Collection recipients) { Timespan duration = this.duration.getOptionalSingle(event).orElse(new Timespan(TimePeriod.TICK, 0)); @@ -62,7 +65,7 @@ protected void setupAsync(Event event, Collection locations, Co } @Override - @NonNull + @NotNull public String toString(@Nullable Event event, boolean debug) { return "draw an animation of the shape of " + shapes.toString(event, debug) + " at " + (locations != null ? locations.toString(event, debug) : "shape's location") + " for " + (players == null ? "all players" : players.toString(event, debug) + " over " + duration.toString(event, debug)); diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ModifierInputs.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ModifierInputs.java new file mode 100644 index 0000000..c635ce2 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ModifierInputs.java @@ -0,0 +1,59 @@ +package com.sovdee.skriptparticles.skript.shaders; + +import ch.njol.skript.lang.SkriptParser.ParseResult; +import com.sovdee.shapes.modifiers.StandardInput; +import org.jetbrains.annotations.Nullable; + +/** + * The shared pattern fragment for choosing a {@link StandardInput}, so every syntax that takes an input accepts + * the same words. Inputs are an inline choice rather than a type: a type whose values are bare words like + * {@code y} would compete with other syntax such as {@code y of %vector%}. + */ +public final class ModifierInputs { + + private ModifierInputs() {} + + private static final String SPATIAL = "input_x:x[-| ]axis|input_y:y[-| ]axis|input_z:z[-| ]axis" + + "|input_radius:radius|input_spherical:spherical distance|input_angle:angle"; + + /** Every input that depends on where a point is; excludes the draw order. */ + public static final String SPATIAL_INPUTS = "(" + SPATIAL + ")"; + + /** Every input, including the draw order. */ + public static final String ALL_INPUTS = "(" + SPATIAL + "|input_t:draw order)"; + + /** + * @return the input chosen by a pattern containing {@link #SPATIAL_INPUTS} or {@link #ALL_INPUTS}, + * or {@code null} if none was chosen + */ + public static @Nullable StandardInput fromTags(ParseResult result) { + for (String tag : result.tags) { + switch (tag) { + case "input_x": return StandardInput.X; + case "input_y": return StandardInput.Y; + case "input_z": return StandardInput.Z; + case "input_radius": return StandardInput.RADIUS; + case "input_spherical": return StandardInput.SPHERICAL; + case "input_angle": return StandardInput.ANGLE; + case "input_t": return StandardInput.T; + default: break; + } + } + return null; + } + + /** + * @return how the input is written in a pattern, for {@code toString} + */ + public static String toString(StandardInput input) { + return switch (input) { + case X -> "x axis"; + case Y -> "y axis"; + case Z -> "z axis"; + case RADIUS -> "radius"; + case SPHERICAL -> "spherical distance"; + case ANGLE -> "angle"; + case T -> "draw order"; + }; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ShadersModule.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ShadersModule.java new file mode 100644 index 0000000..548f905 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/ShadersModule.java @@ -0,0 +1,129 @@ +package com.sovdee.skriptparticles.skript.shaders; + +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.classes.Parser; +import ch.njol.skript.lang.ParseContext; +import ch.njol.skript.registrations.Classes; +import com.sovdee.shapes.modifiers.EasingFunction; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.skript.shaders.effects.EffAddColorStop; +import com.sovdee.skriptparticles.skript.shaders.expressions.ExprEasing; +import com.sovdee.skriptparticles.skript.shaders.constructors.ExprGradientModifier; +import com.sovdee.skriptparticles.skript.shaders.constructors.ExprMotionModifier; +import com.sovdee.skriptparticles.skript.shaders.constructors.ExprScaleModifier; +import com.sovdee.skriptparticles.skript.shaders.constructors.ExprTwistModifier; +import com.sovdee.skriptparticles.skript.shaders.constructors.ExprWaveModifier; +import com.sovdee.skriptparticles.skript.shaders.expressions.ExprNewModifier; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprGradientColors; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprModifierEasing; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprMotionVelocity; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprScaleModifierRange; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprTwistAngle; +import com.sovdee.skriptparticles.skript.shaders.expressions.properties.ExprWaveProperty; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointCoordinate; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointCount; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointDisplacement; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointIndex; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointMotion; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointScale; +import com.sovdee.skriptparticles.skript.shaders.expressions.point.ExprPointVisible; +import com.sovdee.skriptparticles.skript.shaders.sections.ExprSecCustomModifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.addon.AddonModule; +import org.skriptlang.skript.addon.HierarchicalAddonModule; +import org.skriptlang.skript.addon.SkriptAddon; + +public class ShadersModule extends HierarchicalAddonModule { + + public ShadersModule(AddonModule parentModule) { + super(parentModule); + } + + @Override + protected void initSelf(SkriptAddon addon) { + Classes.registerClass(new ClassInfo<>(PointModifier.class, "pointmodifier") + .user("point ?modifiers?") + .name("Point Modifier") + .description("A modifier applied to shape points. Geometry modifiers (scale, twist, wave) move points and are cached with the shape. Render modifiers (gradients, motion, custom shaders) run each time the shape is drawn and change the particles' colour, motion or visibility.") + // inside a modifier constructor section, modifier properties default to the new modifier + .defaultExpression(new ExprNewModifier()) + .parser(new Parser<>() { + @Override + public @Nullable PointModifier parse(String s, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public @NotNull String toString(PointModifier o, int flags) { return o.toString(); } + @Override + public @NotNull String toVariableNameString(PointModifier o) { return "modifier:" + o.getClass().getSimpleName(); } + }) + ); + + Classes.registerClass(new ClassInfo<>(EasingFunction.class, "easing") + .user("easings?") + .name("Easing Function") + .description("A curve that reshapes the normalised input value of modifiers and gradients.", + "Supported curves: linear, quadratic ease in/out/in-out, cubic ease in/out/in-out,", + "sine ease in/out/in-out, power ease with a custom exponent, and custom Skript functions.") + .parser(new Parser<>() { + @Override + public @Nullable EasingFunction parse(String s, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public @NotNull String toString(EasingFunction o, int flags) { + return switch (o) { + case EasingFunction.PowerEasing p -> switch (p.mode()) { + case IN -> "ease in (power " + p.exponent() + ")"; + case OUT -> "ease out (power " + p.exponent() + ")"; + case IN_OUT -> "ease in-out (power " + p.exponent() + ")"; + }; + case EasingFunction.SineEasing s -> switch (s.mode()) { + case IN -> "sine ease in"; + case OUT -> "sine ease out"; + case IN_OUT -> "sine ease in-out"; + }; + default -> "easing"; + }; + } + @Override + public @NotNull String toVariableNameString(EasingFunction o) { return "easing:" + o.easingHash(); } + }) + ); + } + + @Override + protected void loadSelf(SkriptAddon addon) { + register(addon, + ExprGradientModifier::register, + ExprMotionModifier::register, + ExprScaleModifier::register, + ExprTwistModifier::register, + ExprWaveModifier::register, + ExprNewModifier::register, + ExprWaveProperty::register, + ExprTwistAngle::register, + ExprScaleModifierRange::register, + ExprModifierEasing::register, + ExprGradientColors::register, + ExprMotionVelocity::register, + ExprEasing::register, + EffAddColorStop::register, + ExprPointCoordinate::register, + ExprPointDisplacement::register, + ExprPointMotion::register, + ExprPointVisible::register, + ExprPointScale::register, + ExprPointIndex::register, + ExprPointCount::register, + ExprSecCustomModifier::register + ); + } + + @Override + public String name() { + return "shaders"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprGradientModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprGradientModifier.java new file mode 100644 index 0000000..b30db7d --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprGradientModifier.java @@ -0,0 +1,104 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.util.Color; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.skriptparticles.rendering.shaders.AbstractGradientModifier; +import com.sovdee.skriptparticles.rendering.shaders.GradientModifier; +import com.sovdee.skriptparticles.skript.shaders.ModifierInputs; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.Arrays; + +@Name("Gradient Modifier Constructor") +@Description({ + "Creates a modifier that colours a shape's points with a gradient. This only affects particles that can be coloured, like dust.", + "The colours, either a list or 'from red to blue', are spaced evenly along an input: the x, y (default) or z axis, the draw order, the radius " + + "(distance from the shape's y axis), the spherical distance (distance from its center), or the angle around its y axis. " + + "'radial', 'spherical' and 'angular' gradients are short for gradients along those inputs.", + "Use 'add color stop' for colours at uneven positions.", + "This can take a section to set the rest of the modifier's properties, like its easing. " + + "Inside it, 'the new modifier' is the gradient being created, and modifier properties use it by default." +}) +@Example("add a gradient from red to blue along the y axis to modifiers of {_shape}") +@Example("add a colour gradient from red, yellow, green and blue along the angle to modifiers of {_shape}") +@Example("add a radial gradient of white and black to modifiers of {_shape}") +@Example("set modifiers of {_shape} to a spherical gradient of yellow and orange") +@Example(""" + set {_gradient} to a gradient of red and blue along the draw order: + set modifier easing to ease in-out sine + add color stop at 0.5 colored white to the new modifier + """) +@Since("2.0.0") +public class ExprGradientModifier extends ModifierConstructorExpression { + + // a list of colours, or the two-colour "from red to blue" + private static final String COLORS = "((from|of) %-colors%|from %-color% to %-color%)"; + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprGradientModifier.class, PointModifier.class) + .supplier(ExprGradientModifier::new) + .addPatterns( + "[a] [colo[u]r] gradient " + COLORS + " [along [the] " + ModifierInputs.ALL_INPUTS + "]", + "[a[n]] (:radial|:angular|:spherical) [colo[u]r] gradient " + COLORS + ) + .build()); + } + + private @Nullable Expression colors; + private @Nullable Expression from, to; + private StandardInput input; + + @Override + @SuppressWarnings("unchecked") + protected boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult result) { + colors = (Expression) expressions[0]; + from = (Expression) expressions[1]; + to = (Expression) expressions[2]; + if (result.hasTag("radial")) { + input = StandardInput.RADIUS; + } else if (result.hasTag("angular")) { + input = StandardInput.ANGLE; + } else if (result.hasTag("spherical")) { + input = StandardInput.SPHERICAL; + } else { + StandardInput chosen = ModifierInputs.fromTags(result); + input = chosen == null ? StandardInput.Y : chosen; + } + return true; + } + + @Override + protected @Nullable PointModifier create(Event event) { + Color[] values; + if (colors != null) { + values = colors.getArray(event); + } else { + Color start = from.getSingle(event), end = to.getSingle(event); + if (start == null || end == null) + return null; + values = new Color[]{start, end}; + } + if (values.length == 0) + return null; + return new GradientModifier(AbstractGradientModifier.evenlySpaced( + Arrays.stream(values).map(Color::asBukkitColor).toList()), input); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + String of = colors != null ? "of " + colors.toString(event, debug) + : "from " + from.toString(event, debug) + " to " + to.toString(event, debug); + return "a gradient " + of + " along the " + ModifierInputs.toString(input); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprMotionModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprMotionModifier.java new file mode 100644 index 0000000..f15a21f --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprMotionModifier.java @@ -0,0 +1,87 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.rendering.shaders.MotionModifier; +import com.sovdee.skriptparticles.util.VectorConversion; +import org.bukkit.event.Event; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Motion Modifier Constructor") +@Description({ + "Creates a modifier that sends particles moving. Only particles that can move, like flames or end rods, are affected, " + + "and they move faster the higher the particle's speed (its extra value).", + "- clockwise / counterclockwise: around the shape's y axis.", + "- inwards / outwards: towards or away from the shape's center.", + "- a global velocity: every particle moves the same way, in world space.", + "- a relative velocity: each particle moves relative to where it is around the shape. " + + "The x of the vector is outwards from the shape's y axis (negative pulls inwards), " + + "the y is along the shape's y axis, and the z is around it (positive is counterclockwise). " + + "This follows the shape as it rotates, so a relative velocity of vector(0, 1, 1) spirals upwards.", + "With a velocity, its length is the speed: vector(0, 2, 0) moves twice as fast as vector(0, 1, 0).", + "Like the other modifier constructors, this can take a section in which 'the new modifier' is the modifier being created." +}) +@Example("add a motion modifier counterclockwise to modifiers of {_shape}") +@Example("add a motion modifier inwards to modifiers of {_shape}") +@Example("add a motion modifier with velocity vector(0, 1, 0) to modifiers of {_shape}") +@Example(""" + # souls that spiral inwards and up, like water going down a drain in reverse + set particle of {_ring} to sculk soul particle with extra value 0.1 + add a motion modifier with relative velocity vector(-1, 0.5, 2) to modifiers of {_ring} + """) +@Since("2.0.0") +public class ExprMotionModifier extends ModifierConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprMotionModifier.class, PointModifier.class) + .supplier(ExprMotionModifier::new) + .addPatterns( + "[a] motion modifier (:clockwise|:counterclockwise|:inwards|:outwards)", + "[a] motion modifier (with|of) [a] [global|:relative] velocity [of] %vector%" + ) + .priority(SyntaxInfo.SIMPLE) + .build()); + } + + private MotionModifier.Mode mode; + private @Nullable Expression velocity; + + @Override + @SuppressWarnings("unchecked") + protected boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult result) { + if (matchedPattern == 0) { + mode = MotionModifier.Mode.valueOf(result.tags.getFirst().toUpperCase()); + } else { + mode = result.hasTag("relative") ? MotionModifier.Mode.RELATIVE : MotionModifier.Mode.GLOBAL; + velocity = (Expression) expressions[0]; + } + return true; + } + + @Override + protected @Nullable PointModifier create(Event event) { + if (velocity == null) + return new MotionModifier(mode); + Vector vector = velocity.getSingle(event); + if (vector == null) + return null; + return new MotionModifier(mode, VectorConversion.toJOML(vector)); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + if (velocity == null) + return "a motion modifier " + mode.name().toLowerCase(); + return "a motion modifier with " + (mode == MotionModifier.Mode.RELATIVE ? "relative" : "global") + + " velocity " + velocity.toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprScaleModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprScaleModifier.java new file mode 100644 index 0000000..3b8b3ca --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprScaleModifier.java @@ -0,0 +1,80 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ScaleAxes; +import com.sovdee.shapes.modifiers.ScalingModifier; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.skriptparticles.skript.shaders.ModifierInputs; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Scale Modifier Constructor") +@Description({ + "Creates a modifier that scales a shape's points by an amount that changes along an input, " + + "from the start scale where the input is 0 to the end scale where it is 1.", + "The input can be the x, y (default) or z axis, the draw order, the radius (distance from the shape's y axis), " + + "the spherical distance (distance from its center), or the angle around its y axis. " + + "Inputs go from 0 where they are lowest in the shape to 1 where they are highest.", + "By default, it scales the x and z axes, so a scale from 1 to 0 along the y axis makes a taper.", + "This can take a section to set the rest of the modifier's properties, like its easing. " + + "Inside it, 'the new modifier' is the modifier being created, and modifier properties use it by default." +}) +@Example("add a scale from 1 to 0 along the y axis to modifiers of {_shape}") +@Example("add a scale from 1 to 2 along the radius scaling the xyz axes to modifiers of {_shape}") +@Example(""" + set {_taper} to a scale from 1 to 0 along the y axis: + set modifier easing to ease in quadratic + add {_taper} to modifiers of {_shape} + """) +@Since("2.0.0") +public class ExprScaleModifier extends ModifierConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprScaleModifier.class, PointModifier.class) + .supplier(ExprScaleModifier::new) + .addPattern("[a] scale from %number% to %number% [along [the] " + ModifierInputs.ALL_INPUTS + "]" + + " [scaling [the] (axes_x:x|axes_y:y|axes_z:z|axes_xy:xy|axes_xz:xz|axes_yz:yz|axes_xyz:xyz) [axis|axes]]") + .build()); + } + + private Expression start, end; + private StandardInput input; + private ScaleAxes axes = ScaleAxes.XZ; + + @Override + @SuppressWarnings("unchecked") + protected boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult result) { + start = (Expression) expressions[0]; + end = (Expression) expressions[1]; + StandardInput chosen = ModifierInputs.fromTags(result); + input = chosen == null ? StandardInput.Y : chosen; + for (String tag : result.tags) { + if (tag.startsWith("axes_")) + axes = ScaleAxes.valueOf(tag.substring(5).toUpperCase()); + } + return true; + } + + @Override + protected @Nullable PointModifier create(Event event) { + Number from = start.getSingle(event), to = end.getSingle(event); + if (from == null || to == null) + return null; + return new ScalingModifier(from.doubleValue(), to.doubleValue(), input, axes); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "a scale from " + start.toString(event, debug) + " to " + end.toString(event, debug) + + " along the " + ModifierInputs.toString(input) + " scaling the " + axes.name().toLowerCase() + " axes"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprTwistModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprTwistModifier.java new file mode 100644 index 0000000..edb3cdf --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprTwistModifier.java @@ -0,0 +1,75 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.RotationPlane; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.modifiers.TwistModifier; +import com.sovdee.skriptparticles.skript.shaders.ModifierInputs; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Twist Modifier Constructor") +@Description({ + "Creates a modifier that rotates a shape's points by an angle that grows along an input, " + + "from no rotation where the input is 0 to the full angle where it is 1.", + "The angle is in degrees. Skript reads '3.14 radians' as degrees too, so either unit works.", + "The input can be the x, y (default) or z axis, the draw order, the radius (distance from the shape's y axis), " + + "the spherical distance (distance from its center), or the angle around its y axis. " + + "By default, it turns the xz plane, around the y axis.", + "This can take a section to set the rest of the modifier's properties, like its easing. " + + "Inside it, 'the new modifier' is the modifier being created, and modifier properties use it by default." +}) +@Example("add a twist of 360 degrees to modifiers of {_shape}") +@Example("add a twist of 180 degrees along the radius in the xy plane to modifiers of {_shape}") +@Example("add a twist of 3.14 radians to modifiers of {_shape}") +@Since("2.0.0") +public class ExprTwistModifier extends ModifierConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprTwistModifier.class, PointModifier.class) + .supplier(ExprTwistModifier::new) + .addPattern("[a] twist of %number% [along [the] " + ModifierInputs.ALL_INPUTS + "]" + + " [in [the] (plane_xz:xz|plane_xy:xy|plane_yz:yz) plane]") + .build()); + } + + private Expression angle; + private StandardInput input; + private RotationPlane plane = RotationPlane.XZ; + + @Override + @SuppressWarnings("unchecked") + protected boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult result) { + angle = (Expression) expressions[0]; + StandardInput chosen = ModifierInputs.fromTags(result); + input = chosen == null ? StandardInput.Y : chosen; + for (String tag : result.tags) { + if (tag.startsWith("plane_")) + plane = RotationPlane.valueOf(tag.substring(6).toUpperCase()); + } + return true; + } + + @Override + protected @Nullable PointModifier create(Event event) { + Number degrees = angle.getSingle(event); + if (degrees == null) + return null; + return new TwistModifier(Math.toRadians(degrees.doubleValue()), input, plane); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "a twist of " + angle.toString(event, debug) + " along the " + ModifierInputs.toString(input) + + " in the " + plane.name().toLowerCase() + " plane"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprWaveModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprWaveModifier.java new file mode 100644 index 0000000..3fee643 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ExprWaveModifier.java @@ -0,0 +1,78 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.SpatialAxis; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.modifiers.WaveModifier; +import com.sovdee.skriptparticles.skript.shaders.ModifierInputs; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Wave Modifier Constructor") +@Description({ + "Creates a modifier that moves a shape's points along an axis by amplitude × sin(360° × frequency × input + phase), " + + "so a frequency of 1 is one full wave over the input.", + "The input can be the x, y or z axis, the draw order (default), the radius (distance from the shape's y axis), " + + "the spherical distance (distance from its center), or the angle around its y axis. " + + "By default, it moves points along the y axis.", + "This can take a section to set the rest of the modifier's properties, like its phase or easing. " + + "Inside it, 'the new modifier' is the modifier being created, and modifier properties use it by default." +}) +@Example("add a wave with amplitude 0.5 and frequency 2 to modifiers of {_shape}") +@Example("add a wave with amplitude 0.3 and frequency 4 along the x axis displacing the y axis to modifiers of {_shape}") +@Example(""" + add a wave with amplitude 0.5 and frequency 1 along the angle to modifiers of {_shape}: + set wave phase to 90 + """) +@Since("2.0.0") +public class ExprWaveModifier extends ModifierConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprWaveModifier.class, PointModifier.class) + .supplier(ExprWaveModifier::new) + .addPattern("[a] wave with amplitude %number% [and] frequency %number% [along [the] " + ModifierInputs.ALL_INPUTS + "]" + + " [displacing [the] (out_x:x|out_y:y|out_z:z)[-| ]axis]") + .build()); + } + + private Expression amplitude, frequency; + private StandardInput input; + private SpatialAxis outputAxis = SpatialAxis.Y; + + @Override + @SuppressWarnings("unchecked") + protected boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult result) { + amplitude = (Expression) expressions[0]; + frequency = (Expression) expressions[1]; + StandardInput chosen = ModifierInputs.fromTags(result); + input = chosen == null ? StandardInput.T : chosen; + for (String tag : result.tags) { + if (tag.startsWith("out_")) + outputAxis = SpatialAxis.valueOf(tag.substring(4).toUpperCase()); + } + return true; + } + + @Override + protected @Nullable PointModifier create(Event event) { + Number amp = amplitude.getSingle(event), freq = frequency.getSingle(event); + if (amp == null || freq == null) + return null; + return new WaveModifier(amp.doubleValue(), freq.doubleValue(), 0.0, input, outputAxis); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "a wave with amplitude " + amplitude.toString(event, debug) + " and frequency " + frequency.toString(event, debug) + + " along the " + ModifierInputs.toString(input) + " displacing the " + outputAxis.name().toLowerCase() + " axis"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ModifierConstructorExpression.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ModifierConstructorExpression.java new file mode 100644 index 0000000..52e8ca9 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/constructors/ModifierConstructorExpression.java @@ -0,0 +1,89 @@ +package com.sovdee.skriptparticles.skript.shaders.constructors; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.expressions.base.SectionExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.TriggerItem; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Base class for the modifier constructors. Each can take an optional section that runs once, right after the + * modifier is created, in which {@code the new modifier} refers to it (and modifier properties default to it), + * the same way Skript's world border section works. + */ +public abstract class ModifierConstructorExpression extends SectionExpression { + + // static so the default expression for point modifiers can stay stateless; saved and restored around each + // section, so nested constructors see their own modifier + private static final ThreadLocal<@Nullable PointModifier> CURRENT = new ThreadLocal<>(); + + private boolean hasSection = false; + + @Override + public boolean init( + Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult parseResult, + @Nullable SectionNode sectionNode, @Nullable List triggerItems + ) { + if (!initialize(expressions, matchedPattern, hasDelayBefore, parseResult)) + return false; + if (sectionNode != null) { + getParser().setHasDelayBefore(Kleenean.FALSE); + loadCode(sectionNode); + if (!getParser().getHasDelayBefore().isFalse()) { + Skript.error("Delays can't be used within a modifier creation section"); + return false; + } + getParser().setHasDelayBefore(hasDelayBefore); + hasSection = true; + } + return true; + } + + protected abstract boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult parseResult); + + /** + * @return the newly created modifier, or {@code null} if one of the inputs was not set + */ + protected abstract @Nullable PointModifier create(Event event); + + @Override + protected PointModifier @Nullable [] get(Event event) { + PointModifier modifier = create(event); + if (modifier == null) + return new PointModifier[0]; + if (hasSection) { + PointModifier outer = CURRENT.get(); + CURRENT.set(modifier); + try { + runSection(event); + } finally { + CURRENT.set(outer); + } + } + return new PointModifier[]{modifier}; + } + + /** + * @return the modifier whose section is running on this thread, if any + */ + public static @Nullable PointModifier current() { + return CURRENT.get(); + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return PointModifier.class; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/effects/EffAddColorStop.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/effects/EffAddColorStop.java new file mode 100644 index 0000000..e46739a --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/effects/EffAddColorStop.java @@ -0,0 +1,85 @@ +package com.sovdee.skriptparticles.skript.shaders.effects; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Effect; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.util.Color; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.rendering.shaders.AbstractGradientModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +/** + * Skript effect that adds a color stop at a specific normalized position to a gradient modifier. + * See {@link #init}, {@link #execute}, and {@link #toString} for behaviour details. + * Documented from the Skript side via {@code @Name}, {@code @Description}, {@code @Example}, and {@code @Since}. + */ +@Name("Add Gradient Color Stop") +@Description({ + "Adds a color stop at a specific position to a gradient modifier.", + "Position is a number between 0 and 1.", + "This effect is primarily used inside a section body when building a gradient with custom stop positions.", +}) +@Example("add color stop at 0 colored red to {_gradient}") +@Example("add stop at 0.5 colored yellow to {_gradient}") +@Example("add color stop at 1 colored blue to {_gradient}") +@Since("2.0.0") +public class EffAddColorStop extends Effect { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EFFECT, SyntaxInfo.builder(EffAddColorStop.class) + .supplier(EffAddColorStop::new) + .addPatterns("add [color] stop at %number% color[ed] %color% to %pointmodifier%") + .build()); + } + + private Expression position; + private Expression color; + private Expression modifier; + + /** + * {@inheritDoc} + */ + @SuppressWarnings("unchecked") + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { + this.position = (Expression) exprs[0]; + this.color = (Expression) exprs[1]; + this.modifier = (Expression) exprs[2]; + return true; + } + + /** + * Adds the color stop to the modifier only if it is an {@link AbstractGradientModifier}; + * silently does nothing otherwise. + */ + @Override + protected void execute(Event event) { + Number pos = position.getSingle(event); + Color col = color.getSingle(event); + PointModifier mod = modifier.getSingle(event); + + if (pos == null || col == null || mod == null) return; + + if (mod instanceof AbstractGradientModifier gradientModifier) { + gradientModifier.addStop(pos.doubleValue(), col.asBukkitColor()); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String toString(@Nullable Event event, boolean debug) { + return "add color stop at " + position.toString(event, debug) + + " colored " + color.toString(event, debug) + + " to " + modifier.toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprEasing.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprEasing.java new file mode 100644 index 0000000..fa50106 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprEasing.java @@ -0,0 +1,175 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; +import ch.njol.skript.lang.function.FunctionEvent; +import ch.njol.skript.lang.function.Functions; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.EasingFunction; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +@Name("Easing Function") +@Description({ + "Creates an easing function that reshapes the normalised input of modifiers and gradients.", + "Built-in curves: linear, quadratic, cubic, and sine — each available as ease in, ease out, or ease in-out.", + "Power curves accept a custom exponent (e.g. 4 for quartic, 0.5 for square-root).", + "Custom: provide the name of a Skript function that takes one number and returns one number.", + " The function should be a pure math transformation; it runs on whatever thread the modifier runs on.", +}) +@Example("set {_e} to linear easing") +@Example("set {_e} to ease in quadratic") +@Example("set {_e} to ease out cubic") +@Example("set {_e} to ease in-out sine") +@Example("set {_e} to power ease in with exponent 4") +@Example("set {_e} to power ease out with exponent 0.5") +@Example(""" + add a scale from 0 to 1 along the y axis to modifiers of {_shape}: + set modifier easing to ease in quadratic + """) +@Example(""" + add a gradient from red to blue along the y axis to modifiers of {_shape}: + set modifier easing to ease in-out sine + """) +@Example(""" + function myEase(t: number) :: number: + return {_t} ^ 3 + set {_e} to custom easing from function "myEase" + """) +@Since("2.0.0") +public class ExprEasing extends SimpleExpression { + + // Pattern 0: linear + // Patterns 1-3: ease in/out/in-out [(quadratic|cubic|sine)] + // mark: 0=quadratic (default), 1=quadratic explicit, 2=cubic, 3=sine + // Pattern 4: power ease (in|out|in-out) with exponent %number% + // mark: 0=in, 1=out, 2=in-out + // Pattern 5: custom Skript function name + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprEasing.class, EasingFunction.class) + .supplier(ExprEasing::new) + .addPatterns( + "linear [easing]", + "(ease in|in ease) [(0:quadratic|1:quadratic|2:cubic|3:sine)] [easing]", + "(ease out|out ease) [(0:quadratic|1:quadratic|2:cubic|3:sine)] [easing]", + "(ease in[-| ]out|in[-| ]out ease) [(0:quadratic|1:quadratic|2:cubic|3:sine)] [easing]", + "[a] power ease (0:in|1:out|2:in[-| ]out) with exponent %number%", + "[a] custom easing [from [function]] %string%" + ) + .build()); + } + + private int pattern; + private int parseMark; + private @Nullable Expression exponent; // pattern 4 + private @Nullable Expression funcName; // pattern 5 + + @SuppressWarnings("unchecked") + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { + this.pattern = matchedPattern; + this.parseMark = parseResult.mark; + if (matchedPattern == 4) this.exponent = (Expression) exprs[0]; + if (matchedPattern == 5) this.funcName = (Expression) exprs[0]; + return true; + } + + @Override + protected EasingFunction @Nullable [] get(Event event) { + return new EasingFunction[]{ build(event) }; + } + + private EasingFunction build(Event event) { + return switch (pattern) { + case 1 -> curveFromMark(parseMark, EasingFunction.Mode.IN); + case 2 -> curveFromMark(parseMark, EasingFunction.Mode.OUT); + case 3 -> curveFromMark(parseMark, EasingFunction.Mode.IN_OUT); + case 4 -> { + Number n = exponent != null ? exponent.getSingle(event) : null; + double exp = (n != null) ? n.doubleValue() : 2.0; + EasingFunction.Mode mode = switch (parseMark) { + case 1 -> EasingFunction.Mode.OUT; + case 2 -> EasingFunction.Mode.IN_OUT; + default -> EasingFunction.Mode.IN; + }; + yield new EasingFunction.PowerEasing(exp, mode); + } + case 5 -> { + String name = funcName != null ? funcName.getSingle(event) : null; + yield name != null ? new SkriptFunctionEasing(name) : EasingFunction.LINEAR; + } + default -> EasingFunction.LINEAR; + }; + } + + /** + * Selects the right built-in curve for the parse mark (0/1=quad, 2=cubic, 3=sine). + */ + private static EasingFunction curveFromMark(int mark, EasingFunction.Mode mode) { + return switch (mark) { + case 2 -> new EasingFunction.PowerEasing(3.0, mode); // cubic + case 3 -> new EasingFunction.SineEasing(mode); // sine + default -> new EasingFunction.PowerEasing(2.0, mode); // quadratic (0 or 1) + }; + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return EasingFunction.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return switch (pattern) { + case 0 -> "linear easing"; + case 1 -> "ease in"; + case 2 -> "ease out"; + case 3 -> "ease in-out"; + case 4 -> "power ease"; + case 5 -> "custom easing"; + default -> "easing"; + }; + } + + // ------------------------------------------------------------------------- + // Custom Skript-function-backed easing + // ------------------------------------------------------------------------- + + /** + * Calls a user-defined Skript function {@code f(t: number) :: number} as an easing curve. + * The function should be pure math — it runs on whatever thread the modifier does. + */ + private record SkriptFunctionEasing(String name) implements EasingFunction { + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public double apply(double t) { + var fn = Functions.getGlobalFunction(name); + if (fn == null) return t; + try { + FunctionEvent fe = new FunctionEvent(null); + Object[] result = fn.execute(fe, new Object[][]{{t}}); + if (result != null && result.length > 0 && result[0] instanceof Number n) + return n.doubleValue(); + } catch (Exception ignored) {} + return t; + } + + @Override + public int easingHash() { + return name.hashCode(); + } + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprNewModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprNewModifier.java new file mode 100644 index 0000000..b586d0e --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/ExprNewModifier.java @@ -0,0 +1,90 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions; + +import ch.njol.skript.Skript; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.DefaultExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.parser.ParserInstance; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.skript.shaders.constructors.ModifierConstructorExpression; +import com.sovdee.skriptparticles.util.SkriptUtil; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +/** + * The modifier being created by the enclosing modifier constructor section. Also the default point modifier, + * so modifier properties can leave out {@code of ...} inside such a section. + */ +@Name("New Modifier") +@Description({ + "The modifier being created, inside the section of a modifier constructor (scale, twist, wave, gradient or motion).", + "Modifier properties can leave out which modifier they are about inside such a section, and will use this one." +}) +@Example(""" + set {_wave} to a wave with amplitude 0.3 and frequency 2 along the y axis: + set wave phase of the new modifier to 90 + set modifier easing to ease in-out sine + """) +@Since("2.0.0") +public class ExprNewModifier extends SimpleExpression implements DefaultExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprNewModifier.class, PointModifier.class) + .supplier(ExprNewModifier::new) + .addPattern("[the] (new|created|current) [point] modifier") + .build()); + } + + private static boolean inSection() { + return SkriptUtil.getCurrentSectionExpression(ModifierConstructorExpression.class, ParserInstance.get()) != null; + } + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + if (!inSection()) { + Skript.error("'the new modifier' can only be used inside the section of a modifier constructor, such as 'a wave with amplitude 1 and frequency 2:'"); + return false; + } + return true; + } + + @Override + public boolean init() { + // used as the default point modifier: silently unavailable outside a constructor section + return inSection(); + } + + @Override + public boolean isDefault() { + return true; + } + + @Override + protected PointModifier @Nullable [] get(Event event) { + PointModifier modifier = ModifierConstructorExpression.current(); + return modifier == null ? new PointModifier[0] : new PointModifier[]{modifier}; + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return PointModifier.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "the new modifier"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCoordinate.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCoordinate.java new file mode 100644 index 0000000..cb7eb05 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCoordinate.java @@ -0,0 +1,99 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.util.Kleenean; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Coordinate") +@Description("The local X, Y, or Z coordinate of the current point. Can be read and modified.") +@Example(""" + set {_shader} to a custom shader: + set the point's y to point's y + 1 + add 0.5 to the x of point + """) +@Since("2.0.0") +public class ExprPointCoordinate extends PointPropertyExpression { + + // mark: 0=x, 1=y, 2=z + private int axis; + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointCoordinate.class, Number.class) + .supplier(ExprPointCoordinate::new) + .addPatterns( + "[the] point's (0¦x|1¦y|2¦z)", + "[the] (0¦x|1¦y|2¦z) of [the] point" + ) + .build()); + } + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, + SkriptParser.ParseResult parseResult) { + this.axis = parseResult.mark; + return super.init(exprs, matchedPattern, isDelayed, parseResult); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.y; + case 2 -> ctx.z; + default -> ctx.x; + }; + } + + @Override + public @Nullable Class[] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + ParticleRenderContext ctx = getContext(event); + if (ctx == null || delta == null || delta[0] == null) return; + double value = ((Number) delta[0]).doubleValue(); + switch (mode) { + case SET -> setAxis(ctx, value); + case ADD -> setAxis(ctx, getAxis(ctx) + value); + case REMOVE -> setAxis(ctx, getAxis(ctx) - value); + } + } + + private double getAxis(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.y; + case 2 -> ctx.z; + default -> ctx.x; + }; + } + + private void setAxis(ParticleRenderContext ctx, double value) { + switch (axis) { + case 1 -> ctx.y = value; + case 2 -> ctx.z = value; + default -> ctx.x = value; + } + } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "point's " + switch (axis) { case 1 -> "y"; case 2 -> "z"; default -> "x"; }; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCount.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCount.java new file mode 100644 index 0000000..dc2b408 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointCount.java @@ -0,0 +1,41 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Count") +@Description("The total number of points being drawn this pass. Read-only.") +@Example(""" + set {_shader} to a custom shader: + set {_t} to point's index / point count + """) +@Since("2.0.0") +public class ExprPointCount extends PointPropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointCount.class, Number.class) + .supplier(ExprPointCount::new) + .addPatterns( + "[the] point count", + "[the] point count of [the] point", + "[the] point's [total] count" + ) + .build()); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { return ctx.totalPoints; } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { return "point count"; } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointDisplacement.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointDisplacement.java new file mode 100644 index 0000000..0813c4d --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointDisplacement.java @@ -0,0 +1,99 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.util.Kleenean; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Displacement") +@Description("The X, Y, or Z world-space displacement of the current point. Added to the world position at render time.") +@Example(""" + set {_shader} to a custom shader: + add 0.5 to the point's y displacement + set the x displacement of point to 1 + """) +@Since("2.0.0") +public class ExprPointDisplacement extends PointPropertyExpression { + + // mark: 0=x, 1=y, 2=z + private int axis; + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointDisplacement.class, Number.class) + .supplier(ExprPointDisplacement::new) + .addPatterns( + "[the] point's (0¦x|1¦y|2¦z) displacement", + "[the] (0¦x|1¦y|2¦z) displacement of [the] point" + ) + .build()); + } + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, + SkriptParser.ParseResult parseResult) { + this.axis = parseResult.mark; + return super.init(exprs, matchedPattern, isDelayed, parseResult); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.displacementY; + case 2 -> ctx.displacementZ; + default -> ctx.displacementX; + }; + } + + @Override + public @Nullable Class[] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + ParticleRenderContext ctx = getContext(event); + if (ctx == null || delta == null || delta[0] == null) return; + double value = ((Number) delta[0]).doubleValue(); + switch (mode) { + case SET -> setAxis(ctx, value); + case ADD -> setAxis(ctx, getAxis(ctx) + value); + case REMOVE -> setAxis(ctx, getAxis(ctx) - value); + } + } + + private double getAxis(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.displacementY; + case 2 -> ctx.displacementZ; + default -> ctx.displacementX; + }; + } + + private void setAxis(ParticleRenderContext ctx, double value) { + switch (axis) { + case 1 -> ctx.displacementY = value; + case 2 -> ctx.displacementZ = value; + default -> ctx.displacementX = value; + } + } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "point's " + switch (axis) { case 1 -> "y"; case 2 -> "z"; default -> "x"; } + " displacement"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointIndex.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointIndex.java new file mode 100644 index 0000000..d1b7ed6 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointIndex.java @@ -0,0 +1,40 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Index") +@Description("The zero-based draw-order index of the current point. Read-only.") +@Example(""" + set {_shader} to a custom shader: + set {_t} to point's index / point count + """) +@Since("2.0.0") +public class ExprPointIndex extends PointPropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointIndex.class, Number.class) + .supplier(ExprPointIndex::new) + .addPatterns( + "[the] point's index", + "[the] index of [the] point" + ) + .build()); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { return ctx.index; } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { return "point's index"; } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointMotion.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointMotion.java new file mode 100644 index 0000000..373bec6 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointMotion.java @@ -0,0 +1,103 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.util.Kleenean; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Motion") +@Description({ + "The X, Y, or Z motion (velocity) of the current point.", + "Setting any motion component automatically enables motion for this point.", +}) +@Example(""" + set {_shader} to a custom shader: + set the point's y motion to 0.5 + set the x motion of point to point's x / 2 + """) +@Since("2.0.0") +public class ExprPointMotion extends PointPropertyExpression { + + // mark: 0=x, 1=y, 2=z + private int axis; + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointMotion.class, Number.class) + .supplier(ExprPointMotion::new) + .addPatterns( + "[the] point's (0¦x|1¦y|2¦z) motion", + "[the] (0¦x|1¦y|2¦z) motion of [the] point" + ) + .build()); + } + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, + SkriptParser.ParseResult parseResult) { + this.axis = parseResult.mark; + return super.init(exprs, matchedPattern, isDelayed, parseResult); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.motionY; + case 2 -> ctx.motionZ; + default -> ctx.motionX; + }; + } + + @Override + public @Nullable Class[] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + ParticleRenderContext ctx = getContext(event); + if (ctx == null || delta == null || delta[0] == null) return; + float value = ((Number) delta[0]).floatValue(); + switch (mode) { + case SET -> setAxis(ctx, value); + case ADD -> setAxis(ctx, getAxis(ctx) + value); + case REMOVE -> setAxis(ctx, getAxis(ctx) - value); + } + ctx.hasMotion = true; + } + + private float getAxis(ParticleRenderContext ctx) { + return switch (axis) { + case 1 -> ctx.motionY; + case 2 -> ctx.motionZ; + default -> ctx.motionX; + }; + } + + private void setAxis(ParticleRenderContext ctx, float value) { + switch (axis) { + case 1 -> ctx.motionY = value; + case 2 -> ctx.motionZ = value; + default -> ctx.motionX = value; + } + } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "point's " + switch (axis) { case 1 -> "y"; case 2 -> "z"; default -> "x"; } + " motion"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointScale.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointScale.java new file mode 100644 index 0000000..630be2d --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointScale.java @@ -0,0 +1,40 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Scale") +@Description("The scale of the shape being drawn. Read-only.") +@Example(""" + set {_shader} to a custom shader: + set {_s} to point's scale + """) +@Since("2.0.0") +public class ExprPointScale extends PointPropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointScale.class, Number.class) + .supplier(ExprPointScale::new) + .addPatterns( + "[the] point's scale", + "[the] scale of [the] point" + ) + .build()); + } + + @Override + protected Number getValue(ParticleRenderContext ctx) { return ctx.scale; } + + @Override + public Class getReturnType() { return Number.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { return "point's scale"; } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointVisible.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointVisible.java new file mode 100644 index 0000000..d1b39b4 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/ExprPointVisible.java @@ -0,0 +1,57 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Point Visibility") +@Description("Whether the current point should be rendered. Set to false to skip rendering this point entirely.") +@Example(""" + set {_shader} to a custom shader: + set point's visibility to false + if point's visibility is true: + add 1 to point's y + """) +@Since("2.0.0") +public class ExprPointVisible extends PointPropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprPointVisible.class, Boolean.class) + .supplier(ExprPointVisible::new) + .addPatterns( + "[the] point's visibility", + "[the] visibility of [the] point" + ) + .build()); + } + + @Override + protected Boolean getValue(ParticleRenderContext ctx) { + return ctx.visible; + } + + @Override + public @Nullable Class[] acceptChange(ChangeMode mode) { + return mode == ChangeMode.SET ? new Class[]{Boolean.class} : null; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + ParticleRenderContext ctx = getContext(event); + if (ctx == null || delta == null || delta[0] == null) return; + ctx.visible = (Boolean) delta[0]; + } + + @Override + public Class getReturnType() { return Boolean.class; } + + @Override + public String toString(@Nullable Event event, boolean debug) { return "point's visibility"; } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/PointPropertyExpression.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/PointPropertyExpression.java new file mode 100644 index 0000000..b5900d3 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/point/PointPropertyExpression.java @@ -0,0 +1,69 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.point; + +import ch.njol.skript.Skript; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import com.sovdee.skriptparticles.rendering.shaders.CustomSkriptModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Array; + +/** + * Abstract base for expressions that read or write fields of the current + * {@link ParticleRenderContext} inside a {@link CustomSkriptModifier}. + * + *

Subclasses only need to implement {@link #getValue}, {@link #getReturnType}, + * and {@link #toString}. Settable subclasses additionally override + * {@link #acceptChange} and {@link #change}, using {@link #getContext} to + * retrieve the context from the event.

+ */ +public abstract class PointPropertyExpression extends SimpleExpression { + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, + SkriptParser.ParseResult parseResult) { + if (!getParser().isCurrentEvent(CustomSkriptModifier.PointModifierEvent.class)) { + Skript.error("Point properties can only be used inside a custom point modifier"); + return false; + } + return true; + } + + /** + * Returns the current {@link ParticleRenderContext} from the event, + * or {@code null} if the event is not a {@link CustomSkriptModifier.PointModifierEvent}. + */ + protected @Nullable ParticleRenderContext getContext(Event event) { + if (!(event instanceof CustomSkriptModifier.PointModifierEvent pme)) return null; + return pme.getPoint(); + } + + @SuppressWarnings("unchecked") + @Override + protected final T[] get(Event event) { + ParticleRenderContext ctx = getContext(event); + if (ctx == null) return null; + T value = getValue(ctx); + if (value == null) return null; + T[] arr = (T[]) Array.newInstance(getReturnType(), 1); + arr[0] = value; + return arr; + } + + /** + * Returns the value to expose for the given context. + * + * @param ctx the current point context + * @return the value, or {@code null} to yield no result + */ + protected abstract @Nullable T getValue(ParticleRenderContext ctx); + + @Override + public boolean isSingle() { + return true; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprGradientColors.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprGradientColors.java new file mode 100644 index 0000000..1287429 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprGradientColors.java @@ -0,0 +1,105 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.util.Color; +import ch.njol.skript.util.ColorRGB; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.rendering.shaders.AbstractGradientModifier; +import com.sovdee.skriptparticles.rendering.shaders.ColorStop; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Name("Gradient Modifier Colours") +@Description({ + "The colours of a gradient modifier, in order.", + "Setting them, or adding to them, spaces every colour evenly along the gradient again, " + + "replacing any stops added at uneven positions.", + "Inside a modifier constructor section, this can leave out the modifier and use the new one." +}) +@Example("set gradient colours of {_gradient} to red, white and blue") +@Example("add black to {_gradient}'s gradient colors") +@Example("send \"%gradient colours of {_gradient}%\"") +@Since("2.0.0") +public class ExprGradientColors extends PropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprGradientColors.class, Color.class, + "gradient colo[u]rs", "pointmodifiers", true) + .supplier(ExprGradientColors::new) + .build()); + } + + @Override + @SuppressWarnings("unchecked") + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult result) { + setExpr((Expression) expressions[0]); + return true; + } + + @Override + protected Color[] get(Event event, PointModifier[] source) { + List colors = new ArrayList<>(); + for (PointModifier modifier : source) { + if (modifier instanceof AbstractGradientModifier gradient) { + for (ColorStop stop : gradient.getStops()) + colors.add(ColorRGB.fromBukkitColor(stop.color())); + } + } + return colors.toArray(new Color[0]); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD -> new Class[]{Color[].class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + if (delta == null || delta.length == 0) + return; + List given = Arrays.stream(delta).map(c -> ((Color) c).asBukkitColor()).toList(); + for (PointModifier modifier : getExpr().getArray(event)) { + if (!(modifier instanceof AbstractGradientModifier gradient)) + continue; + List colors = new ArrayList<>(); + if (mode == ChangeMode.ADD) { + for (ColorStop stop : gradient.getStops()) + colors.add(stop.color()); + } + colors.addAll(given); + gradient.setColors(colors); + } + } + + @Override + public boolean isSingle() { + return false; + } + + @Override + public Class getReturnType() { + return Color.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "gradient colours of " + getExpr().toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprModifierEasing.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprModifierEasing.java new file mode 100644 index 0000000..7a19d39 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprModifierEasing.java @@ -0,0 +1,72 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import com.sovdee.shapes.modifiers.Easable; +import com.sovdee.shapes.modifiers.EasingFunction; +import com.sovdee.shapes.modifiers.PointModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Modifier Easing") +@Description({ + "The easing of a scale, twist or gradient modifier: the curve that reshapes its input before it is used. " + + "For example, ease in quadratic makes a taper stay wide for longer before narrowing.", + "Resetting or deleting it makes the modifier linear again.", + "Inside a modifier constructor section, this can leave out the modifier and use the new one." +}) +@Example("set modifier easing of {_taper} to ease in quadratic") +@Example(""" + set {_twist} to a twist of 360 degrees along the y axis: + set modifier easing to ease in-out sine + """) +@Example("reset modifier easing of {_twist}") +@Since("2.0.0") +public class ExprModifierEasing extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprModifierEasing.class, EasingFunction.class, + "modifier easing", "pointmodifiers", true) + .supplier(ExprModifierEasing::new) + .build()); + } + + @Override + public @Nullable EasingFunction convert(PointModifier modifier) { + return modifier instanceof Easable easable ? easable.getEasing() : null; + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, RESET, DELETE -> new Class[]{EasingFunction.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + EasingFunction easing = delta == null || delta.length == 0 ? EasingFunction.LINEAR : (EasingFunction) delta[0]; + for (PointModifier modifier : getExpr().getArray(event)) { + if (modifier instanceof Easable easable) + easable.setEasing(easing); + } + } + + @Override + public Class getReturnType() { + return EasingFunction.class; + } + + @Override + protected String getPropertyName() { + return "modifier easing"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprMotionVelocity.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprMotionVelocity.java new file mode 100644 index 0000000..ad145d7 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprMotionVelocity.java @@ -0,0 +1,78 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.rendering.shaders.MotionModifier; +import com.sovdee.skriptparticles.util.VectorConversion; +import org.bukkit.event.Event; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Motion Modifier Velocity") +@Description({ + "The velocity of a motion modifier made with a global or relative velocity. " + + "Whether it is global or relative stays the same when it is changed.", + "Motion modifiers made with clockwise, counterclockwise, inwards or outwards have no velocity.", + "Inside a modifier constructor section, this can leave out the modifier and use the new one." +}) +@Example("set motion velocity of {_wind} to vector(1, 0, 0)") +@Example("add vector(0, 0.5, 0) to motion velocity of modifiers of {_shape}") +@Since("2.0.0") +public class ExprMotionVelocity extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprMotionVelocity.class, Vector.class, "motion velocity", "pointmodifiers", true) + .supplier(ExprMotionVelocity::new) + .build()); + } + + @Override + public @Nullable Vector convert(PointModifier modifier) { + if (modifier instanceof MotionModifier motion && motion.hasVelocity()) + return VectorConversion.toBukkit(motion.getVelocity()); + return null; + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Vector.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + if (delta == null) + return; + Vector given = (Vector) delta[0]; + for (PointModifier modifier : getExpr().getArray(event)) { + if (!(modifier instanceof MotionModifier motion) || !motion.hasVelocity()) + continue; + Vector result = switch (mode) { + case ADD -> VectorConversion.toBukkit(motion.getVelocity()).add(given); + case REMOVE -> VectorConversion.toBukkit(motion.getVelocity()).subtract(given); + default -> given; + }; + motion.setVelocity(VectorConversion.toJOML(result)); + } + } + + @Override + public Class getReturnType() { + return Vector.class; + } + + @Override + protected String getPropertyName() { + return "motion velocity"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprScaleModifierRange.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprScaleModifierRange.java new file mode 100644 index 0000000..b618fe2 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprScaleModifierRange.java @@ -0,0 +1,91 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ScalingModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Scale Modifier Start / End") +@Description({ + "The scale a scale modifier uses where its input is lowest (the start) and where it is highest (the end).", + "Inside a modifier constructor section, these can leave out the modifier and use the new one." +}) +@Example("set scale start of {_taper} to 2") +@Example("set {_taper}'s scale end to 0.5") +@Since("2.0.0") +public class ExprScaleModifierRange extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprScaleModifierRange.class, Number.class, + "scale (:start|end)", "pointmodifiers", true) + .supplier(ExprScaleModifierRange::new) + .build()); + } + + private boolean start; + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult result) { + start = result.hasTag("start"); + return super.init(expressions, matchedPattern, isDelayed, result); + } + + @Override + public @Nullable Number convert(PointModifier modifier) { + if (!(modifier instanceof ScalingModifier scale)) + return null; + return start ? scale.getStartScale() : scale.getEndScale(); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + case RESET, DELETE -> new Class[0]; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + double value = delta == null ? 1 : ((Number) delta[0]).doubleValue(); + for (PointModifier modifier : getExpr().getArray(event)) { + if (!(modifier instanceof ScalingModifier scale)) + continue; + double current = start ? scale.getStartScale() : scale.getEndScale(); + double result = switch (mode) { + case ADD -> current + value; + case REMOVE -> current - value; + // set, or reset/delete back to 1 + default -> value; + }; + if (start) { + scale.setStartScale(result); + } else { + scale.setEndScale(result); + } + } + } + + @Override + public Class getReturnType() { + return Number.class; + } + + @Override + protected String getPropertyName() { + return start ? "scale start" : "scale end"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprTwistAngle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprTwistAngle.java new file mode 100644 index 0000000..80f1919 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprTwistAngle.java @@ -0,0 +1,71 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.TwistModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Twist Modifier Angle") +@Description({ + "How far a twist modifier turns the points where its input is highest, in degrees. Radians work too, since Skript reads '3.14 radians' as degrees.", + "Inside a modifier constructor section, this can leave out the modifier and use the new one." +}) +@Example("set twist angle of {_twist} to 720") +@Example("add 90 to {_twist}'s twist angle") +@Since("2.0.0") +public class ExprTwistAngle extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprTwistAngle.class, Number.class, "twist angle", "pointmodifiers", true) + .supplier(ExprTwistAngle::new) + .build()); + } + + @Override + public @Nullable Number convert(PointModifier modifier) { + return modifier instanceof TwistModifier twist ? Math.toDegrees(twist.getTotalAngle()) : null; + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + if (delta == null) + return; + double value = Math.toRadians(((Number) delta[0]).doubleValue()); + for (PointModifier modifier : getExpr().getArray(event)) { + if (!(modifier instanceof TwistModifier twist)) + continue; + twist.setTotalAngle(switch (mode) { + case ADD -> twist.getTotalAngle() + value; + case REMOVE -> twist.getTotalAngle() - value; + default -> value; + }); + } + } + + @Override + public Class getReturnType() { + return Number.class; + } + + @Override + protected String getPropertyName() { + return "twist angle"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprWaveProperty.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprWaveProperty.java new file mode 100644 index 0000000..9207fd2 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/expressions/properties/ExprWaveProperty.java @@ -0,0 +1,107 @@ +package com.sovdee.skriptparticles.skript.shaders.expressions.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.WaveModifier; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Wave Modifier Properties") +@Description({ + "The amplitude, frequency and phase of a wave modifier.", + "The amplitude is how far points are moved, in blocks. The frequency is how many full waves fit along the input. " + + "The phase shifts the wave along, in degrees: 180 flips it. Radians work too, since Skript reads '3.14 radians' as degrees.", + "Inside a modifier constructor section, these can leave out the modifier and use the new one." +}) +@Example("set wave amplitude of {_wave} to 1.5") +@Example("add 1 to {_wave}'s wave frequency") +@Example(""" + set {_wave} to a wave with amplitude 0.5 and frequency 1 along the y axis: + set wave phase to 90 + """) +@Since("2.0.0") +public class ExprWaveProperty extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprWaveProperty.class, Number.class, + "wave (:amplitude|:frequency|:phase)", "pointmodifiers", true) + .supplier(ExprWaveProperty::new) + .build()); + } + + private enum Part { AMPLITUDE, FREQUENCY, PHASE } + + private Part part; + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult result) { + part = Part.valueOf(result.tags.getFirst().toUpperCase()); + return super.init(expressions, matchedPattern, isDelayed, result); + } + + @Override + public @Nullable Number convert(PointModifier modifier) { + if (!(modifier instanceof WaveModifier wave)) + return null; + return switch (part) { + case AMPLITUDE -> wave.getAmplitude(); + case FREQUENCY -> wave.getFrequency(); + case PHASE -> Math.toDegrees(wave.getPhase()); + }; + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE -> new Class[]{Number.class}; + case RESET, DELETE -> part == Part.PHASE ? new Class[0] : null; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + double value = delta == null ? 0 : ((Number) delta[0]).doubleValue(); + for (PointModifier modifier : getExpr().getArray(event)) { + if (!(modifier instanceof WaveModifier wave)) + continue; + double current = switch (part) { + case AMPLITUDE -> wave.getAmplitude(); + case FREQUENCY -> wave.getFrequency(); + case PHASE -> Math.toDegrees(wave.getPhase()); + }; + double result = switch (mode) { + case SET -> value; + case ADD -> current + value; + case REMOVE -> current - value; + default -> 0; + }; + switch (part) { + case AMPLITUDE -> wave.setAmplitude(result); + case FREQUENCY -> wave.setFrequency(result); + case PHASE -> wave.setPhase(Math.toRadians(result)); + } + } + } + + @Override + public Class getReturnType() { + return Number.class; + } + + @Override + protected String getPropertyName() { + return "wave " + part.name().toLowerCase(); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/sections/ExprSecCustomModifier.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/sections/ExprSecCustomModifier.java new file mode 100644 index 0000000..be649ee --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shaders/sections/ExprSecCustomModifier.java @@ -0,0 +1,97 @@ +package com.sovdee.skriptparticles.skript.shaders.sections; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.SectionExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.Trigger; +import ch.njol.skript.lang.TriggerItem; +import ch.njol.skript.variables.Variables; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.skriptparticles.rendering.shaders.CustomSkriptModifier; +import com.sovdee.skriptparticles.rendering.shaders.CustomSkriptModifier.PointModifierEvent; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Name("Custom Point Modifier") +@Description({ + "Creates a point modifier whose behaviour is defined by the section below it.", + "The section runs once for every point of the shape, every time the shape is rendered.", + "Use the 'point' expressions (point's x/y/z, point's index, point's visibility, etc.) to read and change the current point.", + "Local variables from the surrounding code are copied into the modifier when it is created; changes to them inside", + "the section persist from point to point during a single render, but never affect the outside code.", + "Warning: the section runs off the main thread and once per point per frame - keep it lightweight and never", + "access worlds, entities, or other game state from it. Delays are not allowed." +}) +@Example(""" + set {_shader} to a custom point modifier: + add 0.3 * sin(point's index * 20) to point's y + if mod(point's index, 4) = 0: + set point's visibility to false + add {_shader} to modifiers of {_shape} + """) +@Since("2.0.0") +public class ExprSecCustomModifier extends SectionExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprSecCustomModifier.class, PointModifier.class) + .supplier(ExprSecCustomModifier::new) + .addPatterns( + "[a] [new] custom [point] (modifier|shader)" + ) + .build()); + } + + private Trigger trigger; + + @Override + public boolean init(Expression[] expressions, int pattern, Kleenean delayed, ParseResult result, + @Nullable SectionNode node, @Nullable List triggerItems) { + assert node != null; // isSectionOnly guarantees a section + AtomicBoolean delayDetected = new AtomicBoolean(false); + Runnable afterLoading = () -> delayDetected.set(!getParser().getHasDelayBefore().isFalse()); + trigger = loadCode(node, "custom point modifier", null, afterLoading, PointModifierEvent.class); + if (delayDetected.get()) { + Skript.error("Delays can't be used within a custom point modifier section"); + return false; + } + return true; + } + + @Override + public boolean isSectionOnly() { + return true; + } + + @Override + protected PointModifier @Nullable [] get(Event event) { + return new PointModifier[]{new CustomSkriptModifier(trigger, Variables.copyLocalVariables(event))}; + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return PointModifier.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "a custom point modifier"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapeProperties.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapeProperties.java new file mode 100644 index 0000000..d3aaeac --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapeProperties.java @@ -0,0 +1,82 @@ +package com.sovdee.skriptparticles.skript.shapes; + +import ch.njol.skript.classes.Changer.ChangeMode; +import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.Shape; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.lang.properties.handlers.base.ExpressionPropertyHandler; + +/** + * Handlers for Skript's common type properties, so Skript's own {@code style of} and {@code scale of} + * work for shapes without competing with other types' versions of those expressions. + */ +final class ShapeProperties { + + private ShapeProperties() {} + + static final String STYLE_DESCRIPTION = """ + How a shape is drawn: as an outline (wireframe), as a surface (hollow), or filled (solid). + See the shape style type for the full list of names."""; + + static final String SCALE_DESCRIPTION = """ + How much a shape is scaled up or down around its center. Scaling does not change the number of particles, \ + so a scaled-up shape looks sparser. A negative scale flips the shape inside out. \ + Deleting it sets it back to 1."""; + + static final class StyleHandler implements ExpressionPropertyHandler { + + @Override + public @Nullable SamplingStyle convert(Shape shape) { + return shape.getPointSampler().getStyle(); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return mode == ChangeMode.SET ? new Class[]{SamplingStyle.class} : null; + } + + @Override + public void change(Shape shape, Object @Nullable [] delta, ChangeMode mode) { + if (mode == ChangeMode.SET && delta != null && delta.length > 0) + shape.getPointSampler().setStyle((SamplingStyle) delta[0]); + } + + @Override + public Class returnType() { + return SamplingStyle.class; + } + } + + static final class ScaleHandler implements ExpressionPropertyHandler { + + @Override + public Number convert(Shape shape) { + return shape.getScale(); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE, RESET, DELETE -> new Class[]{Number.class}; + case REMOVE_ALL -> null; + }; + } + + @Override + public void change(Shape shape, Object @Nullable [] delta, ChangeMode mode) { + double value = delta != null && delta.length > 0 ? ((Number) delta[0]).doubleValue() : 1; + switch (mode) { + case SET -> shape.setScale(value); + case ADD -> shape.setScale(shape.getScale() + value); + case REMOVE -> shape.setScale(shape.getScale() - value); + case RESET, DELETE -> shape.setScale(1); + default -> {} + } + } + + @Override + public Class returnType() { + return Number.class; + } + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapesModule.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapesModule.java new file mode 100644 index 0000000..e26014c --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/ShapesModule.java @@ -0,0 +1,266 @@ +package com.sovdee.skriptparticles.skript.shapes; + +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.classes.Parser; +import ch.njol.skript.lang.ParseContext; +import ch.njol.skript.registrations.Classes; +import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.CutoffShape; +import com.sovdee.shapes.shapes.LWHShape; +import com.sovdee.shapes.shapes.PolyShape; +import com.sovdee.shapes.shapes.RadialShape; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.skript.shapes.expressions.ExprCurrentShape; +import com.sovdee.skriptparticles.skript.shapes.expressions.ExprRotation; +import com.sovdee.skriptparticles.skript.shapes.expressions.ExprShapeCopy; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprArc; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprBezierCurve; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprCircle; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprCuboid; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprEllipse; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprEllipsoid; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprEllipticalArc; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprHeart; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprHelix; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprIrregularPolygon; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprLine; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprRectangle; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprRegularPolygon; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprRegularPolyhedron; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprSphere; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprSphericalCap; +import com.sovdee.skriptparticles.skript.shapes.constructors.ExprStar; +import com.sovdee.skriptparticles.skript.shapes.effects.EffRotateShape; +import com.sovdee.skriptparticles.skript.shapes.effects.EffSetOrdering; +import com.sovdee.skriptparticles.skript.shapes.effects.EffToggleAxes; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprHelixWindingRate; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeBounds; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeCutoffAngle; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeLWH; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeLocations; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeModifiers; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeNormal; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeOffset; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeOrientation; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeParticle; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeParticleDensity; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapePointLimit; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapePoints; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeReach; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeRadius; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeRelativeAxis; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeSideLength; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprShapeSides; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprStarPoints; +import com.sovdee.skriptparticles.skript.shapes.properties.ExprStarRadii; +import com.sovdee.skriptparticles.util.Quaternion; +import org.bukkit.boss.BarStyle; +import org.jetbrains.annotations.Nullable; +import org.joml.Quaternionf; +import org.skriptlang.skript.addon.AddonModule; +import org.skriptlang.skript.addon.HierarchicalAddonModule; +import org.skriptlang.skript.addon.SkriptAddon; +import org.skriptlang.skript.lang.converter.Converters; +import org.skriptlang.skript.lang.properties.Property; + +public class ShapesModule extends HierarchicalAddonModule { + + public ShapesModule(AddonModule parentModule) { + super(parentModule); + } + + @Override + protected void initSelf(SkriptAddon addon) { + registerShapeTypes(addon); + registerRotationTypes(); + } + + private static void registerShapeTypes(SkriptAddon addon) { + Classes.registerClass(new ClassInfo<>(Shape.class, "shape") + .user("shapes?") + .name("Shape") + .description("Represents an abstract particle shape. E.g. circle, line, etc.") + .parser(new Parser<>() { + @Override + public Shape parse(String input, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public String toString(Shape o, int flags) { return o.toString(); } + @Override + public String toVariableNameString(Shape shape) { return "shape:" + shape.getPointSampler().getUUID(); } + }) + .cloner(Shape::clone) + .property(Property.STYLE, ShapeProperties.STYLE_DESCRIPTION, addon, new ShapeProperties.StyleHandler()) + .property(Property.SCALE, ShapeProperties.SCALE_DESCRIPTION, addon, new ShapeProperties.ScaleHandler()) + ); + + Classes.registerClass(new ClassInfo<>(RadialShape.class, "radialshape") + .user("radial ?shapes?") + .name("Radial Shape") + .description("Represents an abstract particle shape that has a radius. E.g. circle, sphere, etc.") + .parser(new Parser<>() { + @Override + public RadialShape parse(String input, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public String toString(RadialShape o, int flags) { return o.toString(); } + @Override + public String toVariableNameString(RadialShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } + }) + ); + + Classes.registerClass(new ClassInfo<>(LWHShape.class, "lwhshape") + .user("lwh ?shapes?") + .name("Length/Width/Height Shape") + .description("Represents an abstract particle shape that has a length, width, and/or height. E.g. cube, cylinder, ellipse, etc.") + .parser(new Parser<>() { + @Override + public LWHShape parse(String input, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public String toString(LWHShape o, int flags) { return o.toString(); } + @Override + public String toVariableNameString(LWHShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } + }) + ); + + Classes.registerClass(new ClassInfo<>(CutoffShape.class, "cutoffshape") + .user("cutoff ?shapes?") + .name("Cutoff Shape") + .description("Represents an abstract particle shape that has a cutoff angle. E.g. arc, spherical cap, etc.") + .parser(new Parser<>() { + @Override + public CutoffShape parse(String input, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public String toString(CutoffShape o, int flags) { return o.toString(); } + @Override + public String toVariableNameString(CutoffShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } + }) + ); + + Classes.registerClass(new ClassInfo<>(PolyShape.class, "polyshape") + .user("poly ?shapes?") + .name("Polygonal/Polyhedral Shape") + .description( + "Represents an abstract particle shape that is a polygon or polyhedron, with a side length and side count.\n" + + "Irregular shapes are included in this category, but do not support changing either side count or side length." + ) + .parser(new Parser<>() { + @Override + public PolyShape parse(String input, ParseContext context) { return null; } + @Override + public boolean canParse(ParseContext context) { return false; } + @Override + public String toString(PolyShape o, int flags) { return o.toString(); } + @Override + public String toVariableNameString(PolyShape shape) { return "shape:" + shape.getPointSampler().getUUID(); } + }) + ); + + // Allow Shape-typed expressions (current shape, drawn shape, ...) to be used where a + // dimension-specific shape interface is expected. The converters filter by instanceof. + Converters.registerConverter(Shape.class, RadialShape.class, shape -> shape instanceof RadialShape radial ? radial : null); + Converters.registerConverter(Shape.class, LWHShape.class, shape -> shape instanceof LWHShape lwh ? lwh : null); + Converters.registerConverter(Shape.class, CutoffShape.class, shape -> shape instanceof CutoffShape cutoff ? cutoff : null); + Converters.registerConverter(Shape.class, PolyShape.class, shape -> shape instanceof PolyShape poly ? poly : null); + + Classes.registerClass(new ClassInfo<>(SamplingStyle.class, "shapestyle") + .user("shape ?styles?") + .name("Shape Style") + .description("Represents the way the shape is drawn. Outlined is a wireframe representation, Surface is filling in all the surfaces of the shape, and Filled is filling in the entire shape.") + .parser(new Parser<>() { + @Override + public @Nullable SamplingStyle parse(String s, ParseContext context) { + s = s.toUpperCase(); + if (s.matches("OUTLINE(D)?") || s.matches("WIREFRAME")) { + return SamplingStyle.OUTLINE; + } else if (s.matches("SURFACE") || s.matches("HOLLOW")) { + return SamplingStyle.SURFACE; + } else if (s.matches("FILL(ED)?") || s.matches("SOLID")) { + return SamplingStyle.FILL; + } + return null; + } + @Override + public boolean canParse(ParseContext context) { return true; } + @Override + public String toString(SamplingStyle style, int i) { return style.toString(); } + @Override + public String toVariableNameString(SamplingStyle style) { return "shapestyle:" + style; } + })); + + // 'solid' is also a boss bar style, and when the type being changed isn't known until runtime + // (e.g. 'set style of {_shape} to solid') Skript may parse the literal as the boss bar style + Converters.registerConverter(BarStyle.class, SamplingStyle.class, + style -> style == BarStyle.SOLID ? SamplingStyle.FILL : null); + } + + private static void registerRotationTypes() { + Converters.registerConverter(Quaternionf.class, Quaternion.class, Quaternion::new); + } + + @Override + protected void loadSelf(SkriptAddon addon) { + register(addon, + // shape + ExprCurrentShape::register, + // shape factory expressions + ExprCircle::register, + ExprSphere::register, + ExprCuboid::register, + ExprLine::register, + ExprArc::register, + ExprEllipse::register, + ExprEllipsoid::register, + ExprRegularPolygon::register, + ExprRegularPolyhedron::register, + ExprHelix::register, + ExprBezierCurve::register, + ExprIrregularPolygon::register, + ExprRectangle::register, + ExprHeart::register, + ExprSphericalCap::register, + ExprStar::register, + ExprEllipticalArc::register, + // misc shape expressions + ExprShapeCopy::register, + ExprRotation::register, + // shape property expressions + ExprShapeRadius::register, + ExprShapeParticle::register, + ExprShapeOffset::register, + ExprShapeOrientation::register, + ExprShapeNormal::register, + ExprShapeParticleDensity::register, + ExprShapePointLimit::register, + ExprShapeBounds::register, + ExprShapeReach::register, + ExprShapeRelativeAxis::register, + ExprShapeLWH::register, + ExprShapeCutoffAngle::register, + ExprShapeSides::register, + ExprShapeSideLength::register, + ExprShapePoints::register, + ExprShapeLocations::register, + ExprShapeModifiers::register, + ExprHelixWindingRate::register, + ExprStarPoints::register, + ExprStarRadii::register, + // shape effects + EffRotateShape::register, + EffSetOrdering::register, + EffToggleAxes::register + ); + } + + @Override + public String name() { + return "shapes"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprArc.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprArc.java similarity index 62% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprArc.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprArc.java index b137d0c..ee82e21 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprArc.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprArc.java @@ -1,43 +1,48 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Arc; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Arc or Sector") -@Description({ - "Creates an arc or sector with the given radius and cutoff angle. The radius must be greater than 0 and the height, if given, must be positive.", - "The angle must be between 0 and 360 degrees. If the angle is 360 degrees, the shape will be a circle or cylinder.", - "An arc is a portion of the circle's circumference. A sector is a portion of the circle's area." -}) -@Examples({ - "set {_shape} to an arc with radius 10 and angle 45 degrees", - "set {_shape} to a circular sector of radius 3 and angle 90 degrees", - "set {_shape} to a sector of radius 3 and height 5 and angle 90 degrees", - "set {_shape} to a cylindrical sector of radius 1, height 0.5, and angle 45" -}) +@Description(""" + Creates an arc or sector with the given radius and cutoff angle. The radius must be greater than 0 and the height, if given, must be positive. + The angle must be between 0 and 360 degrees. If the angle is 360 degrees, the shape will be a circle or cylinder. + An arc is a portion of the circle's circumference. A sector is a portion of the circle's area. + """) +@Example("set {_shape} to an arc with radius 10 and angle 45 degrees") +@Example("set {_shape} to a circular sector of radius 3 and angle 90 degrees") +@Example("set {_shape} to a sector of radius 3 and height 5 and angle 90 degrees") +@Example("set {_shape} to a cylindrical sector of radius 1, height 0.5, and angle 45") @Since("1.0.0") -public class ExprArc extends SimpleExpression { - - static { - Skript.registerExpression(ExprArc.class, Shape.class, ExpressionType.COMBINED, - "[a[n]] [circular] (arc|:sector) (with|of) radius %number% and [cutoff] angle [of] %number% [degrees|:radians]", - "[a[n]] [cylindrical] (arc|:sector) (with|of) radius %number%(,| and) height %-number%[,] and [cutoff] angle [of] %number% [degrees|:radians]"); +public class ExprArc extends ShapeConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register( + SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprArc.class, Shape.class) + .supplier(ExprArc::new) + .addPatterns( + "[a[n]] [circular] (arc|:sector) (with|of) radius %number% and [cutoff] angle [of] %number% [degrees|:radians]", + "[a[n]] [cylindrical] (arc|:sector) (with|of) radius %number%(,| and) height %-number%[,] and [cutoff] angle [of] %number% [degrees|:radians]" + ) + .build()); } private Expression radius; @@ -47,7 +52,8 @@ public class ExprArc extends SimpleExpression { private boolean isSector = false; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { radius = (Expression) exprs[0]; if (matchedPattern == 1) { height = (Expression) exprs[1]; @@ -80,8 +86,7 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number radius = this.radius.getSingle(event); Number angle = this.angle.getSingle(event); Number height = (this.height != null) ? this.height.getSingle(event) : 0; @@ -100,17 +105,7 @@ protected Shape[] get(Event event) { shape.getPointSampler().setStyle(SamplingStyle.FILL); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprBezierCurve.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprBezierCurve.java new file mode 100644 index 0000000..8c135f7 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprBezierCurve.java @@ -0,0 +1,109 @@ +package com.sovdee.skriptparticles.skript.shapes.constructors; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.shapes.BezierCurve; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.rendering.DrawData; +import com.sovdee.skriptparticles.util.Point; +import com.sovdee.skriptparticles.util.VectorConversion; +import org.bukkit.Location; +import org.bukkit.util.Vector; +import org.bukkit.event.Event; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector3d; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +@Name("Particle Bezier Curve") +@Description(""" + Creates a bezier curve between the given start and end points, using the given control points to change the curve. + """) +@Example("set {_shape} to a bezier curve from {a} to {b} with control points {c} and {d}") +@Example("set {_shape} to a curve from player to player's target with control point (location 3 above player)") +@Since("1.3.0") +public class ExprBezierCurve extends ShapeConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprBezierCurve.class, Shape.class) + .supplier(ExprBezierCurve::new) + .addPatterns("[a] [bezier] curve from [start] %vector/entity/location% to [end] %vector/entity/location% (with|using) control point[s] %vectors/entities/locations%") + .build()); + } + + private Expression start; + private Expression end; + private Expression controlPoints; + + @Override + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + start = exprs[0]; + end = exprs[1]; + controlPoints = exprs[2]; + return true; + } + + @Override + protected @Nullable List getShapes(Event event) { + @Nullable Point startPt = Point.of(this.start.getSingle(event)); + @Nullable Point endPt = Point.of(this.end.getSingle(event)); + if (startPt == null || endPt == null) + return null; + List> controlPts = new ArrayList<>(); + for (Object value : this.controlPoints.getArray(event)) { + controlPts.add(Point.of(value)); + } + BezierCurve curve = getBezierCurve(startPt, controlPts, endPt); + if (curve == null) + return null; + return List.of(curve); + } + + private static @Nullable BezierCurve getBezierCurve(@NotNull Point startPt, List> controlPts, @NotNull Point endPt) { + List> others = new ArrayList<>(controlPts); + others.add(endPt); + + // The curve is anchored at its start. A vector start has no place in the world, + // so the other points cannot be world positions then. + boolean relative = startPt.getType() == Vector.class; + boolean dynamic = startPt.isDynamic(); + for (Point point : others) { + if (point == null || (relative && point.getType() != Vector.class)) + return null; + dynamic |= point.isDynamic(); + } + + // every point as an offset from the start + Supplier> points = () -> { + Location origin = startPt.getLocation(); + List result = new ArrayList<>(others.size() + 1); + result.add(VectorConversion.toJOML(startPt.getVector(origin))); + for (Point point : others) + result.add(VectorConversion.toJOML(point.getVector(origin))); + return result; + }; + + // only follow the points if any of them can actually move + BezierCurve curve = dynamic ? new BezierCurve(points) : new BezierCurve(points.get()); + curve.getPointSampler().setDrawContext(new DrawData()); + if (!relative) + DrawData.of(curve).setLocation(startPt.getDynamicLocation()); + return curve; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "bezier curve between start " + start.toString(event, debug) + " and end " + end.toString(event, debug) + + " using control points " + controlPoints.toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCircle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCircle.java similarity index 66% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCircle.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCircle.java index a71f273..f8cb4cb 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCircle.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCircle.java @@ -1,41 +1,44 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Circle; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.NonNull; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Circle or Cylinder") -@Description({ - "Creates a circle, disc, or cylinder shape with the given radius. The radius must be greater than 0 and the height cannot be negative." -}) -@Examples({ - "set {_shape} to circle with radius 10", - "set {_shape} to a disc of radius 3", - "set {_shape} to a solid cylinder with radius 3 and height 5" -}) +@Description(""" + Creates a circle, disc, or cylinder shape with the given radius. The radius must be greater than 0 and the height cannot be negative. + """) +@Example("set {_shape} to circle with radius 10") +@Example("set {_shape} to a disc of radius 3") +@Example("set {_shape} to a solid cylinder with radius 3 and height 5") @Since("1.0.0") -public class ExprCircle extends SimpleExpression { +public class ExprCircle extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprCircle.class, Shape.class, ExpressionType.COMBINED, - "[a] (circle|:disc) (with|of) radius %number%", - "[a] [hollow|2:solid] (cylinder|1:tube) (with|of) radius %number% and height %number%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprCircle.class, Shape.class) + .supplier(ExprCircle::new) + .addPatterns( + "[a] (circle|:disc) (with|of) radius %number%", + "[a] [hollow|2:solid] (cylinder|1:tube) (with|of) radius %number% and height %number%" + ) + .build()); } private Expression radius; @@ -44,7 +47,8 @@ public class ExprCircle extends SimpleExpression { private boolean isCylinder; @Override - public boolean init(Expression[] exprs, int matchedPattern, @NonNull Kleenean isDelayed, @NonNull ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { isCylinder = matchedPattern == 1; radius = (Expression) exprs[0]; @@ -74,8 +78,7 @@ public boolean init(Expression[] exprs, int matchedPattern, @NonNull Kleenean } @Override - @Nullable - protected Shape[] get(@NonNull Event event) { + protected @Nullable List getShapes(Event event) { Number radius = this.radius.getSingle(event); Number height = this.height != null ? this.height.getSingle(event) : 0; if (radius == null || height == null) @@ -87,22 +90,10 @@ protected Shape[] get(@NonNull Event event) { Circle shape = new Circle(radius.doubleValue(), height.doubleValue()); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - @NonNull - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override - @NonNull public String toString(@Nullable Event event, boolean debug) { return (isCylinder ? "circle of radius " + radius.toString(event, debug) : "cylinder of radius " + radius.toString(event, debug) + " and height " + (height != null ? height.toString(event, debug) : "0")); diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCuboid.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCuboid.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCuboid.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCuboid.java index e0f0d21..4962cbb 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprCuboid.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprCuboid.java @@ -1,47 +1,51 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Cuboid; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.DynamicLocation; import com.sovdee.skriptparticles.util.MathUtil; import com.sovdee.skriptparticles.util.VectorConversion; +import org.joml.Vector3d; +import com.sovdee.skriptparticles.util.RelativePoints; import org.bukkit.event.Event; import org.bukkit.util.Vector; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Cuboid") -@Description({ - "Creates a cuboid from a length, a width, and a height, or from two corners.", - "The specified length, width, and height must be greater than 0. Length is the x-axis, width is the z-axis, and height is the y-axis.", - "When defining a cuboid from two corners, the corners can either be vectors or locations/entities. " + - "You cannot use both vectors and locations/entities, but you can mix and match locations and entities." + - "When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations.", -}) -@Examples({ - "set {_shape} to a solid cuboid with length 10, width 10, and height 10", - "set {_shape} to a hollow cuboid from vector(-5, -5, -5) to vector(5, 5, 5)", - "draw the shape of a cuboid from player to player's target" -}) +@Description(""" + Creates a cuboid from a length, a width, and a height, or from two corners. + The specified length, width, and height must be greater than 0. Length is the x-axis, width is the z-axis, and height is the y-axis. + When defining a cuboid from two corners, the corners can either be vectors or locations/entities. + You cannot use both vectors and locations/entities, but you can mix and match locations and entities. + When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations. + """) +@Example("set {_shape} to a solid cuboid with length 10, width 10, and height 10") +@Example("set {_shape} to a hollow cuboid from vector(-5, -5, -5) to vector(5, 5, 5)") +@Example("draw the shape of a cuboid from player to player's target") @Since("1.0.0") -public class ExprCuboid extends SimpleExpression { - - static { - Skript.registerExpression(ExprCuboid.class, Shape.class, ExpressionType.COMBINED, - "[a] [:hollow|:solid] cuboid (with|of) length %number%(,| and) width %number%[,] and height %number%", - "[a] [:hollow|:solid] cuboid (from|between) %location/entity/vector% (to|and) %location/entity/vector%"); +public class ExprCuboid extends ShapeConstructorExpression { + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprCuboid.class, Shape.class) + .supplier(ExprCuboid::new) + .addPatterns( + "[a] [:hollow|:solid] cuboid (with|of) length %number%(,| and) width %number%[,] and height %number%", + "[a] [:hollow|:solid] cuboid (from|between) %location/entity/vector% (to|and) %location/entity/vector%" + ) + .build()); } private Expression width; @@ -50,11 +54,11 @@ public class ExprCuboid extends SimpleExpression { private Expression corner1; private Expression corner2; private int matchedPattern = 0; - private SamplingStyle style; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { switch (matchedPattern) { case 0 -> { length = (Expression) exprs[0]; @@ -78,10 +82,9 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Shape shape; - // from width, length, height + DynamicLocation anchor = null; if (matchedPattern == 0) { if (width == null || length == null || height == null) return null; Number width = this.width.getSingle(event); @@ -92,14 +95,12 @@ protected Shape[] get(Event event) { length = Math.max(length.doubleValue(), MathUtil.EPSILON); height = Math.max(height.doubleValue(), MathUtil.EPSILON); shape = new Cuboid(length.doubleValue(), width.doubleValue(), height.doubleValue()); - // from location/entity/vector to location/entity/vector } else { if (corner1 == null || corner2 == null) return null; Object corner1 = this.corner1.getSingle(event); Object corner2 = this.corner2.getSingle(event); if (corner1 == null || corner2 == null) return null; - // vector check if (corner1 instanceof Vector && corner2 instanceof Vector) { shape = new Cuboid(VectorConversion.toJOML((Vector) corner1), VectorConversion.toJOML((Vector) corner2)); } else if (corner1 instanceof Vector || corner2 instanceof Vector) { @@ -109,26 +110,21 @@ protected Shape[] get(Event event) { DynamicLocation dl2 = DynamicLocation.fromLocationEntity(corner2); if (dl1 == null || dl2 == null) return null; - // Use Supplier-based Cuboid for dynamic corners - shape = new Cuboid( - () -> VectorConversion.toJOML(dl1.getLocation().toVector()), - () -> VectorConversion.toJOML(dl2.getLocation().toVector()) - ); + // The cuboid is anchored at its first corner: its corners are relative to it, + // and it is stored as the location the cuboid is drawn at by default. + if (dl1.isDynamic() || dl2.isDynamic()) { + shape = new Cuboid(RelativePoints.origin(), RelativePoints.supplier(dl1, dl2)); + } else { + shape = new Cuboid(new Vector3d(), RelativePoints.offset(dl1, dl2)); + } + anchor = dl1; } } shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + if (anchor != null) + DrawData.of(shape).setLocation(anchor); + return List.of(shape); } @Override @@ -144,6 +140,5 @@ public String toString(@Nullable Event event, boolean debug) { case 1 -> "from " + corner1.toString(event, debug) + " to " + corner2.toString(event, debug); default -> ""; }; - } } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipse.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipse.java similarity index 64% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipse.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipse.java index 40f2a55..4813ac4 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipse.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipse.java @@ -1,43 +1,47 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Ellipse; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Ellipse or Elliptical Cylinder") -@Description({ - "Creates a ellipse, elliptical disc, or elliptical cylinder shape with the given radii. The radii must be greater than 0.", - "The first radius is the x radius, and the second radius is the z radius. These are relative to the shape's rotation, " + - "so they only correspond exactly to the x and z axes if the shape is not rotated.", - "NOTE: Very eccentric elliptical discs/sectors (those with a large difference between the x and z radii) may have many more particles than expected. Be careful." -}) -@Examples({ - "set {_shape} to oval with radii 10 and 3", - "set {_shape} to a solid ellipse of radius 3 and 5", - "set {_shape} to a hollow elliptical cylinder with radii 3 and 6 and height 5" -}) +@Description(""" + Creates an ellipse, elliptical disc, or elliptical cylinder shape with the given radii. The radii must be greater than 0. + The first radius is the x radius, and the second radius is the z radius. These are relative to the shape's rotation, + so they only correspond exactly to the x and z axes if the shape is not rotated. + NOTE: Very eccentric elliptical discs/sectors (those with a large difference between the x and z radii) may have many more particles than expected. Be careful. + """) +@Example("set {_shape} to oval with radii 10 and 3") +@Example("set {_shape} to a solid ellipse of radius 3 and 5") +@Example("set {_shape} to a hollow elliptical cylinder with radii 3 and 6 and height 5") @Since("1.0.0") -public class ExprEllipse extends SimpleExpression { +public class ExprEllipse extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprEllipse.class, Shape.class, ExpressionType.COMBINED, - "[a[n]] [surface:(solid|filled)] (ellipse|oval) (with|of) radi(i|us) %number% and %number%", - "[a[n]] [hollow|2:solid] elliptical (cylinder|1:tube) (with|of) radi(i|us) %number%(,| and) %number%[,] and height %number%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprEllipse.class, Shape.class) + .supplier(ExprEllipse::new) + .addPatterns( + "[a[n]] [surface:(solid|filled)] (ellipse|oval) (with|of) radi(i|us) %number% and %number%", + "[a[n]] [hollow|2:solid] elliptical (cylinder|1:tube) (with|of) radi(i|us) %number%(,| and) %number%[,] and height %number%" + ) + .build()); } private Expression xRadius; @@ -46,7 +50,8 @@ public class ExprEllipse extends SimpleExpression { private SamplingStyle style; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { xRadius = (Expression) exprs[0]; zRadius = (Expression) exprs[1]; style = SamplingStyle.OUTLINE; @@ -84,8 +89,7 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number xRadius = this.xRadius.getSingle(event); Number zRadius = this.zRadius.getSingle(event); Number height = this.height == null ? 0 : this.height.getSingle(event); @@ -99,17 +103,7 @@ protected Shape[] get(Event event) { Ellipse shape = new Ellipse(xRadius.doubleValue(), zRadius.doubleValue(), height.doubleValue()); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipsoid.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipsoid.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipsoid.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipsoid.java index 7a5c822..fd19cd7 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipsoid.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipsoid.java @@ -1,44 +1,46 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Ellipsoid; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Ellipsoid") -@Description({ - "Creates a ellipsoid shape with the given radii. The radii must be greater than 0.", - "The first radius is the x radius, and the second is the y radius, and the last is the z radius. " + - "These are relative to the shape's rotation, so they only correspond exactly to the world axes if the shape is not rotated.", - "Note that this shape is modified using the Length/Width/Height modifiers, not the Radius modifier. This means the length/width/height " + - "of the shape will be twice the radius in each direction. Length is the x axis, width is the z axis, and height is the y axis.", - "NOTE: Very eccentric solid ellipsoids (those with a large difference between the radii) may have many more particles than expected. Be careful." -}) -@Examples({ - "set {_shape} to ellipsoid with radii 10, 3, and 8", - "set {_shape} to a solid ellipsoid of radius 3 and 5 and 6", - "set {_shape} to a hollow ellipsoid with radii 3, 6 and 5" -}) +@Description(""" + Creates an ellipsoid shape with the given radii. The radii must be greater than 0. + The first radius is the x radius, the second is the y radius, and the last is the z radius. + These are relative to the shape's rotation, so they only correspond exactly to the world axes if the shape is not rotated. + Note that this shape is modified using the Length/Width/Height modifiers, not the Radius modifier. This means the length/width/height + of the shape will be twice the radius in each direction. Length is the x axis, width is the z axis, and height is the y axis. + NOTE: Very eccentric solid ellipsoids (those with a large difference between the radii) may have many more particles than expected. Be careful. + """) +@Example("set {_shape} to ellipsoid with radii 10, 3, and 8") +@Example("set {_shape} to a solid ellipsoid of radius 3 and 5 and 6") +@Example("set {_shape} to a hollow ellipsoid with radii 3, 6 and 5") @Since("1.0.0") -public class ExprEllipsoid extends SimpleExpression { +public class ExprEllipsoid extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprEllipsoid.class, Shape.class, ExpressionType.COMBINED, - "[a[n]] [(outlined|wireframe|:hollow|fill:(solid|filled))] ellipsoid (with|of) radi(i|us) %number%(,| and) %number%[,] and %number%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprEllipsoid.class, Shape.class) + .supplier(ExprEllipsoid::new) + .addPatterns("[a[n]] [(outlined|wireframe|:hollow|fill:(solid|filled))] ellipsoid (with|of) radi(i|us) %number%(,| and) %number%[,] and %number%") + .build()); } private Expression xRadius; @@ -47,7 +49,8 @@ public class ExprEllipsoid extends SimpleExpression { private SamplingStyle style; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { xRadius = (Expression) exprs[0]; yRadius = (Expression) exprs[1]; zRadius = (Expression) exprs[2]; @@ -75,8 +78,7 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number xRadius = this.xRadius.getSingle(event); Number yRadius = this.yRadius.getSingle(event); Number zRadius = this.zRadius.getSingle(event); @@ -90,22 +92,11 @@ protected Shape[] get(Event event) { Ellipsoid shape = new Ellipsoid(xRadius.doubleValue(), yRadius.doubleValue(), zRadius.doubleValue()); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override public String toString(@Nullable Event event, boolean debug) { return "ellipsoid with radii " + xRadius.toString(event, debug) + ", " + yRadius.toString(event, debug) + ", and " + zRadius.toString(event, debug); } - } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipticalArc.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipticalArc.java similarity index 63% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipticalArc.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipticalArc.java index 29ef979..b924053 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprEllipticalArc.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprEllipticalArc.java @@ -1,43 +1,47 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.EllipticalArc; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Elliptical Arc or Sector") -@Description({ - "Creates an elliptical arc or sector with the given radii and cutoff angle. The radii must be greater than 0 and the height, if given, must be positive.", - "The angle must be between 0 and 360 degrees. If the angle is 360 degrees, the shape will be a ellipse or elliptical cylinder.", - "An arc is a portion of the ellipse's circumference. A sector is a portion of the ellipse's area.", - "NOTE: Very eccentric elliptical sectors (those with a large difference between the x and z radii) may have many more particles than expected. Be careful." -}) -@Examples({ - "set {_shape} to an elliptical arc with radii 10 and 3 and cutoff angle of 90 degrees", - "set {_shape} to a elliptical sector of radius 3 and 5 and cutoff angle of 45 degrees", - "set {_shape} to a elliptical cylinder with radii 3 and 5, height 10, and cutoff angle of 3.1415 radians" -}) +@Description(""" + Creates an elliptical arc or sector with the given radii and cutoff angle. The radii must be greater than 0 and the height, if given, must be positive. + The angle must be between 0 and 360 degrees. If the angle is 360 degrees, the shape will be an ellipse or elliptical cylinder. + An arc is a portion of the ellipse's circumference. A sector is a portion of the ellipse's area. + NOTE: Very eccentric elliptical sectors (those with a large difference between the x and z radii) may have many more particles than expected. Be careful. + """) +@Example("set {_shape} to an elliptical arc with radii 10 and 3 and cutoff angle of 90 degrees") +@Example("set {_shape} to an elliptical sector of radius 3 and 5 and cutoff angle of 45 degrees") +@Example("set {_shape} to an elliptical cylindrical sector with radii 3 and 5, height 10, and cutoff angle of 3.1415 radians") @Since("1.0.0") -public class ExprEllipticalArc extends SimpleExpression { - - static { - Skript.registerExpression(ExprEllipticalArc.class, Shape.class, ExpressionType.COMBINED, - "[an] elliptical (arc|:sector) (with|of) radi(i|us) %number%(,| and) %number%[,] and [cutoff] angle [of] %number% [degrees|:radians]", - "[an] elliptical [cylindrical] (arc|:sector) (with|of) radi(i|us) %number%(,| and) %number%(,| and) height %-number%[,] and [cutoff] angle [of] %number% [degrees|:radians]"); +public class ExprEllipticalArc extends ShapeConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprEllipticalArc.class, Shape.class) + .supplier(ExprEllipticalArc::new) + .addPatterns( + "[an] elliptical (arc|:sector) (with|of) radi(i|us) %number%(,| and) %number%[,] and [cutoff] angle [of] %number% [degrees|:radians]", + "[an] elliptical [cylindrical] (arc|:sector) (with|of) radi(i|us) %number%(,| and) %number%(,| and) height %-number%[,] and [cutoff] angle [of] %number% [degrees|:radians]" + ) + .build()); } private Expression xRadius; @@ -48,10 +52,8 @@ public class ExprEllipticalArc extends SimpleExpression { private boolean isRadians; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { -// Skript.error("Elliptical arcs are currently disabled. If you know how to efficiently compute the inverse of the " + -// "elliptic integral of the second kind, please send me a message on Discord or Github."); -// return false; + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { xRadius = (Expression) exprs[0]; zRadius = (Expression) exprs[1]; if (matchedPattern == 1) { @@ -92,8 +94,7 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number xRadius = this.xRadius.getSingle(event); Number zRadius = this.zRadius.getSingle(event); Number height = this.height == null ? 0 : this.height.getSingle(event); @@ -111,17 +112,7 @@ protected Shape[] get(Event event) { EllipticalArc shape = new EllipticalArc(xRadius.doubleValue(), zRadius.doubleValue(), height.doubleValue(), angle.doubleValue()); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHeart.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHeart.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHeart.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHeart.java index c807907..ee51630 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHeart.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHeart.java @@ -1,41 +1,45 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Heart; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Heart") -@Description({ - "Creates a heart shape with the given width and height, and optionally eccentricity. The width (x) and length (z) must be greater than 0.", - "The eccentricity defaults to 3, but must be at least 1. This determines how round/pointy the heart is. Values between 1 and 5 are recommended.", - "Note that the width and length are not exact, but they're roughly the width and length of the heart.", - "Finally, this shape does not support the particle count expression and its particle density is not uniform. If anyone knows a good way to compute the complete elliptic integral of the second kind, please let me know." -}) -@Examples({ - "set {_heart} to heart with width 5 and length 4", - "set {_heart} to heart shape with width 5, length 7, and eccentricity 2", - "draw the shape of a heart of width 2 and length 2 at player" -}) +@Description(""" + Creates a heart shape with the given width and height, and optionally eccentricity. The width (x) and length (z) must be greater than 0. + The eccentricity defaults to 3, but must be at least 1. This determines how round/pointy the heart is. Values between 1 and 5 are recommended. + Note that the width and length are not exact, but they're roughly the width and length of the heart. + Finally, the particle density of this shape is not uniform. + If anyone knows a good way to compute the complete elliptic integral of the second kind, please let me know. + """) +@Example("set {_heart} to heart with width 5 and length 4") +@Example("set {_heart} to heart shape with width 5, length 7, and eccentricity 2") +@Example("draw the shape of a heart of width 2 and length 2 at player") @Since("1.0.1") -public class ExprHeart extends SimpleExpression { +public class ExprHeart extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprHeart.class, Shape.class, ExpressionType.COMBINED, "[a] [:solid] heart [shape] (with|of) width %number%[,] [and] length %number%[[,] [and] eccentricity %-number%]"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprHeart.class, Shape.class) + .supplier(ExprHeart::new) + .addPatterns("[a] [:solid] heart [shape] (with|of) width %number%[,] [and] length %number%[[,] [and] eccentricity %-number%]") + .build()); } private Expression width; @@ -44,7 +48,8 @@ public class ExprHeart extends SimpleExpression { private boolean isSolid; @Override - public boolean init(Expression[] expressions, int i, Kleenean kleenean, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] expressions, int matchedPattern, Kleenean kleenean, ParseResult parseResult) { width = (Expression) expressions[0]; length = (Expression) expressions[1]; if (expressions.length > 2) { @@ -67,39 +72,26 @@ public boolean init(Expression[] expressions, int i, Kleenean kleenean, Parse } isSolid = parseResult.hasTag("solid"); - return true; } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number width = this.width.getSingle(event); Number length = this.length.getSingle(event); Number eccentricity = this.eccentricity == null ? 3 : this.eccentricity.getSingle(event); - if (width == null || length == null || eccentricity == null) { + if (width == null || length == null || eccentricity == null) return null; - } width = Math.max(width.doubleValue(), MathUtil.EPSILON); length = Math.max(length.doubleValue(), MathUtil.EPSILON); eccentricity = Math.max(eccentricity.doubleValue(), 1); - Heart shape = new Heart(width.doubleValue(), length.doubleValue(), eccentricity.doubleValue()); + Heart shape = new Heart(length.doubleValue(), width.doubleValue(), eccentricity.doubleValue()); if (isSolid) { shape.getPointSampler().setStyle(SamplingStyle.SURFACE); } shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHelix.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHelix.java similarity index 64% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHelix.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHelix.java index 83c8cae..d04dfd6 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprHelix.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprHelix.java @@ -1,41 +1,43 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Helix; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Helix / Spiral") -@Description({ - "Creates a helix or spiral shape with the given radius and height. The radius and height must be greater than 0.", - "The winding rate is the number of loops per meter or block. If omitted, the winding rate will be 1 loop per block.", - "The height of the helix can be manipulated through both the length and height expressions." -}) -@Examples({ - "set {_shape} to helix with radius 10 and height 5", - "set {_shape} to a spiral with radius 3 and height 5 and winding rate of 2 loops per meter", - "set {_shape} to a solid anti-clockwise helix with radius 3, height 5, winding rate 1" -}) +@Description(""" + Creates a helix or spiral shape with the given radius and height. The radius and height must be greater than 0. + The winding rate is the number of loops per meter or block. If omitted, the winding rate will be 1 loop per block. + The height of the helix can be manipulated through both the length and height expressions. + """) +@Example("set {_shape} to helix with radius 10 and height 5") +@Example("set {_shape} to a spiral with radius 3 and height 5 and winding rate of 2 loops per meter") +@Example("set {_shape} to a solid anti-clockwise helix with radius 3, height 5, winding rate 1") @Since("1.0.0") -public class ExprHelix extends SimpleExpression { +public class ExprHelix extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprHelix.class, Shape.class, ExpressionType.COMBINED, - "[a[n]] [:solid] [[1:(counter|anti)[-]]clockwise] (helix|spiral) (with|of) radius %number%[,] [and] height %number%[[,] [and] winding rate [of] %-number% [loops per (meter|block)]]"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprHelix.class, Shape.class) + .supplier(ExprHelix::new) + .addPatterns("[a[n]] [:solid] [[1:(counter|anti)[-]]clockwise] (helix|spiral) (with|of) radius %number%[,] [and] height %number%[[,] [and] winding rate [of] %-number% [loops per (meter|block)]]") + .build()); } private Expression radius; @@ -46,7 +48,8 @@ public class ExprHelix extends SimpleExpression { private SamplingStyle style; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { radius = (Expression) exprs[0]; height = (Expression) exprs[1]; if (exprs.length > 2) @@ -76,13 +79,12 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { @Nullable Number radius = this.radius.getSingle(event); @Nullable Number height = this.height.getSingle(event); @Nullable Number windingRate = this.windingRate == null ? 1 : this.windingRate.getSingle(event); if (radius == null || height == null || windingRate == null) - return new Shape[0]; + return null; radius = Math.max(radius.doubleValue(), MathUtil.EPSILON); height = Math.max(height.doubleValue(), MathUtil.EPSILON); @@ -91,17 +93,7 @@ protected Shape[] get(Event event) { Helix shape = new Helix(radius.doubleValue(), height.doubleValue(), slope / (2 * Math.PI), direction); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override @@ -110,5 +102,4 @@ public String toString(@Nullable Event event, boolean debug) { radius.toString(event, debug) + ", height " + height.toString(event, debug) + (windingRate == null ? "" : ", and winding rate " + windingRate.toString(event, debug)); } - } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprIrregularPolygon.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprIrregularPolygon.java similarity index 62% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprIrregularPolygon.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprIrregularPolygon.java index 864fdc0..e09ad5d 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprIrregularPolygon.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprIrregularPolygon.java @@ -1,19 +1,17 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.IrregularPolygon; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.DynamicLocation; import com.sovdee.skriptparticles.util.VectorConversion; import org.bukkit.Location; @@ -21,34 +19,37 @@ import org.bukkit.util.Vector; import org.jetbrains.annotations.Nullable; import org.joml.Vector3d; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import java.util.ArrayList; import java.util.List; @Name("Particle Irregular Polygon") -@Description({ - "Creates an irregular polygon from a list of vectors or locations. If locations are used, the polygon can be drawn without giving a specific location to draw at.", - "The height of the polygon will be the height between the lowest and highest points. It can also be set with the optional height parameter.", - "", - "Irregular polygons currently only support the wireframe style. Also, they do not currently support Dynamic Locations like lines and cuboids do." -}) -@Examples({ - "set {_shape} to a polygon with points vector(0, 0, 0), vector(1, 0, 0), and vector(1, 1, 1)", - "set {_shape} to a 2d polygon from points vector(0,0,1), vector(1,0,1), vector(0,0,-1) and height 0.5" -}) +@Description(""" + Creates an irregular polygon from a list of vectors or locations. If locations are used, the polygon can be drawn without giving a specific location to draw at. + The height of the polygon will be the height between the lowest and highest points. It can also be set with the optional height parameter. + + Irregular polygons currently only support the wireframe style. Also, they do not currently support Dynamic Locations like lines and cuboids do. + """) +@Example("set {_shape} to a polygon with points vector(0, 0, 0), vector(1, 0, 0), and vector(1, 1, 1)") +@Example("set {_shape} to a 2d polygon from points vector(0,0,1), vector(1,0,1) and vector(0,0,-1) and height 0.5") @Since("1.0.0") -public class ExprIrregularPolygon extends SimpleExpression { +public class ExprIrregularPolygon extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprIrregularPolygon.class, Shape.class, ExpressionType.COMBINED, - "[a] [2d] polygon (from|with) [vertices|points] %vectors/locations% [and height %-number%]"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprIrregularPolygon.class, Shape.class) + .supplier(ExprIrregularPolygon::new) + .addPatterns("[a] [2d] polygon (from|with) [vertices|points] %vectors/locations% [and height %-number%]") + .build()); } private Expression points; private Expression height; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { points = exprs[0]; if (exprs.length > 1) { height = (Expression) exprs[1]; @@ -59,13 +60,11 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye return false; } } - return true; } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Object[] points = this.points.getArray(event); List vertices = new ArrayList<>(points.length); Vector locationOffset = null; @@ -91,17 +90,7 @@ protected Shape[] get(Event event) { if (locationOffset != null) { DrawData.of(shape).setLocation(new DynamicLocation((Location) points[0])); } - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprLine.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprLine.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprLine.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprLine.java index 0b9ba2d..fdf018e 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprLine.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprLine.java @@ -1,71 +1,73 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.Line; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.DynamicLocation; import com.sovdee.skriptparticles.util.MathUtil; import com.sovdee.skriptparticles.util.VectorConversion; +import org.joml.Vector3d; +import com.sovdee.skriptparticles.util.RelativePoints; import org.bukkit.event.Event; import org.bukkit.util.Vector; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import java.util.ArrayList; import java.util.List; @Name("Particle Line") -@Description({ - "Creates a line shape between points, or in a direction for a given length. The length must be greater than 0.", - "When defining a line from points, the points can either be vectors or locations/entities. Each point in the first set will connect to each point in the second set. " + - "You can use the third pattern to connect points in series, like a path along the points.", - "", - "You cannot use both vectors and locations/entities, but you can mix and match locations and entities." + - "When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations.", - "If using vectors, or a direction and length, the shape does require a location to be drawn at." -}) -@Examples({ - "set {_shape} to line from vector(0, 0, 0) to vector(10, 10, 10)", - "set {_shape} to a line in direction vector(1, 1, 1) and length 10", - "draw the shape of a line from vector(0, 0, 0) to vector(10, 10, 10) at player", - "", - "# note that the following does not require a location to be drawn at", - "draw the shape of a line from player to player's target", - "draw the shape of a line from player to (all players in radius 10 of player)", - "draw the shape of a line connecting {_locations::*}" -}) +@Description(""" + Creates a line shape between points, or in a direction for a given length. The length must be greater than 0. + When defining a line from points, the points can either be vectors or locations/entities. Each point in the first set will connect to each point in the second set. + You can use the third pattern to connect points in series, like a path along the points. + + You cannot use both vectors and locations/entities, but you can mix and match locations and entities. + When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations. + If using vectors, or a direction and length, the shape does require a location to be drawn at. + """) +@Example("set {_shape} to line from vector(0, 0, 0) to vector(10, 10, 10)") +@Example("set {_shape} to a line in direction vector(1, 1, 1) and length 10") +@Example("draw the shape of a line from vector(0, 0, 0) to vector(10, 10, 10) at player") +@Example("") +@Example("# note that the following does not require a location to be drawn at") +@Example("draw the shape of a line from player to player's target") +@Example("draw the shape of a line from player to (all players in radius 10 of player)") +@Example("draw the shape of a line connecting {_locations::*}") @Since("1.0.0") -public class ExprLine extends SimpleExpression { - - static { - Skript.registerExpression(ExprLine.class, Shape.class, ExpressionType.COMBINED, - "[a] line[s] (from|between) %locations/entities/vectors% (to|and) %locations/entities/vectors%", - "[a] line (in [the]|from) direction %vector% [(and|[and] with) length %number%]", - "[a] line (between|connecting) %locations/entities/vectors%"); +public class ExprLine extends ShapeConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprLine.class, Shape.class) + .supplier(ExprLine::new) + .addPatterns( + "[a] line[s] (from|between) %locations/entities/vectors% (to|and) %locations/entities/vectors%", + "[a] line (in [the]|from) direction %vector% [(and|[and] with) length %number%]", + "[a] line (between|connecting) %locations/entities/vectors%" + ) + .build()); } private Expression start; private Expression end; - private Expression points; - private Expression direction; private Expression length; - private int matchedPattern = 0; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { switch (matchedPattern) { case 0 -> { start = exprs[0]; @@ -88,14 +90,8 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye return true; } - private Shape attachDrawData(Shape shape) { - shape.getPointSampler().setDrawContext(new DrawData()); - return shape; - } - @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { List lines = new ArrayList<>(); switch (matchedPattern) { case 0 -> { @@ -114,11 +110,9 @@ protected Shape[] get(Event event) { if (endPoint == null || startPoint == null) { continue; } - // Use Supplier-based Line for dynamic endpoints - lines.add(attachDrawData(new Line( - () -> VectorConversion.toJOML(startPoint.getLocation().toVector()), - () -> VectorConversion.toJOML(endPoint.getLocation().toVector()) - ))); + Shape line = lineBetween(startPoint, endPoint); + if (line != null) + lines.add(line); } } } @@ -152,10 +146,9 @@ protected Shape[] get(Event event) { for (int i = 0; i < locations.size() - 1; i++) { DynamicLocation startLoc = locations.get(i); DynamicLocation endLoc = locations.get(i + 1); - lines.add(attachDrawData(new Line( - () -> VectorConversion.toJOML(startLoc.getLocation().toVector()), - () -> VectorConversion.toJOML(endLoc.getLocation().toVector()) - ))); + Shape line = lineBetween(startLoc, endLoc); + if (line != null) + lines.add(line); } } } @@ -163,7 +156,33 @@ protected Shape[] get(Event event) { return null; } } - return lines.toArray(new Shape[0]); + return lines; + } + + private Shape attachDrawData(Shape shape) { + shape.getPointSampler().setDrawContext(new DrawData()); + return shape; + } + + /** + * Creates a line between two world positions. The line is anchored at its start: its points are relative + * to the start, and the start is stored as the location the line is drawn at by default. + * + * @return the line, or null if both positions are currently the same and neither can move + */ + private @Nullable Shape lineBetween(DynamicLocation start, DynamicLocation end) { + Line line; + if (start.isDynamic() || end.isDynamic()) { + line = new Line(RelativePoints.origin(), RelativePoints.supplier(start, end)); + } else { + Vector3d offset = RelativePoints.offset(start, end); + if (offset.lengthSquared() == 0) + return null; + line = new Line(new Vector3d(), offset); + } + attachDrawData(line); + DrawData.of(line).setLocation(start); + return line; } @Override @@ -175,11 +194,6 @@ public boolean isSingle() { }; } - @Override - public Class getReturnType() { - return Shape.class; - } - @Override public String toString(@Nullable Event event, boolean debug) { return switch (matchedPattern) { diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRectangle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRectangle.java similarity index 55% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRectangle.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRectangle.java index 6bebf24..b799773 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRectangle.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRectangle.java @@ -1,52 +1,56 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.SkriptParser; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.Rectangle; import com.sovdee.shapes.shapes.Rectangle.Plane; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.DynamicLocation; import com.sovdee.skriptparticles.util.MathUtil; import com.sovdee.skriptparticles.util.VectorConversion; +import org.joml.Vector3d; +import com.sovdee.skriptparticles.util.RelativePoints; import org.bukkit.event.Event; import org.bukkit.util.Vector; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Rectangle") -@Description({ - "Creates a rectangle from a length and a width, or from two corners. The length and width must be greater than 0.", - "When defining a rectangle from two corners, the corners can either be vectors or locations/entities. " + - "You cannot use both vectors and locations/entities, but you can mix and match locations and entities. " + - "When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations.", - "Note that the rectangle defaults to the xz plane, or parallel to the ground, with x being width and z being length. " + - "You can change this to the xy or yz plane by using the 'xy' or 'yz'. In all cases, the first axis is length and the second is width." -}) -@Examples({ - "set {_shape} to rectangle with length 10 and width 5", - "set {_shape} to a yz rectangle from vector(0, 0, 0) to vector(10, 10, 10)", - "draw the shape of a rectangle with length 10 and width 5 at player", - "", - "# note that the following does not require a location to be drawn at", - "draw the shape of a rectangle from player to player's target" -}) +@Description(""" + Creates a rectangle from a length and a width, or from two corners. The length and width must be greater than 0. + When defining a rectangle from two corners, the corners can either be vectors or locations/entities. + You cannot use both vectors and locations/entities, but you can mix and match locations and entities. + When using locations, this is a shape that can be drawn without a specific location. It will be drawn between the two given locations. + Note that the rectangle defaults to the xz plane, or parallel to the ground, with x being width and z being length. + You can change this to the xy or yz plane by using the 'xy' or 'yz'. In all cases, the first axis is length and the second is width. + """) +@Example("set {_shape} to rectangle with length 10 and width 5") +@Example("set {_shape} to a yz rectangle from vector(0, 0, 0) to vector(10, 10, 10)") +@Example("draw the shape of a rectangle with length 10 and width 5 at player") +@Example("") +@Example("# note that the following does not require a location to be drawn at") +@Example("draw the shape of a rectangle from player to player's target") @Since("1.0.0") -public class ExprRectangle extends SimpleExpression { +public class ExprRectangle extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprRectangle.class, Shape.class, ExpressionType.COMBINED, - "[a[n]] [solid:(solid|filled)] [:xz|:xy|:yz] rectangle (with|of) length %number% and width %number%", - "[a[n]] [solid:(solid|filled)] [:xz|:xy|:yz] rectangle (from|with corners [at]) %location/entity/vector% (to|and) %location/entity/vector%" - ); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprRectangle.class, Shape.class) + .supplier(ExprRectangle::new) + .addPatterns( + "[a[n]] [solid:(solid|filled)] [:xz|:xy|:yz] rectangle (with|of) length %number% and width %number%", + "[a[n]] [solid:(solid|filled)] [:xz|:xy|:yz] rectangle (from|with corners [at]) %location/entity/vector% (to|and) %location/entity/vector%" + ) + .build()); } private Expression lengthExpr; @@ -58,7 +62,8 @@ public class ExprRectangle extends SimpleExpression { private Plane plane; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { style = parseResult.hasTag("solid") ? SamplingStyle.SURFACE : SamplingStyle.OUTLINE; this.matchedPattern = matchedPattern; if (matchedPattern == 0) { @@ -75,8 +80,9 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - protected @Nullable Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Shape shape; + DynamicLocation anchor = null; if (matchedPattern == 0) { if (lengthExpr == null || widthExpr == null) return null; Number length = lengthExpr.getSingle(event); @@ -100,27 +106,21 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye DynamicLocation dl2 = DynamicLocation.fromLocationEntity(corner2); if (dl1 == null || dl2 == null) return null; - // Use Supplier-based Rectangle for dynamic corners - shape = new Rectangle( - () -> VectorConversion.toJOML(dl1.getLocation().toVector()), - () -> VectorConversion.toJOML(dl2.getLocation().toVector()), - plane - ); + // The rectangle is anchored at its first corner: its corners are relative to it, + // and it is stored as the location the rectangle is drawn at by default. + if (dl1.isDynamic() || dl2.isDynamic()) { + shape = new Rectangle(RelativePoints.origin(), RelativePoints.supplier(dl1, dl2), plane); + } else { + shape = new Rectangle(new Vector3d(), RelativePoints.offset(dl1, dl2), plane); + } + anchor = dl1; } } shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + if (anchor != null) + DrawData.of(shape).setLocation(anchor); + return List.of(shape); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolygon.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolygon.java similarity index 67% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolygon.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolygon.java index 5254a76..95b3bb2 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprRegularPolygon.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolygon.java @@ -1,45 +1,49 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.skript.lang.util.SimpleLiteral; import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; import com.sovdee.shapes.shapes.RegularPolygon; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.shapes.sampling.SamplingStyle; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Regular Polygon") -@Description({ - "Creates a regular polygon with the given number of sides and radius. The number of sides must be at least 3. " + - "The radius must be greater than 0." -}) -@Examples({ - "set {_shape} to a regular polygon with 5 sides and radius 10", - "set {_shape} to a solid regular polygon with 6 sides and side length 3", - "draw the shape of a triangle with side length 5 at player" -}) -public class ExprRegularPolygon extends SimpleExpression { +@Description(""" + Creates a regular polygon with the given number of sides and radius. The number of sides must be at least 3 and the radius must be greater than 0. + """) +@Example("set {_shape} to a regular polygon with 5 sides and radius 10") +@Example("set {_shape} to a solid regular polygon with 6 sides and side length 3") +@Example("draw the shape of a triangle with side length 5 at player") +@Since("1.0.0") +public class ExprRegularPolygon extends ShapeConstructorExpression { // TODO: add ExprRegularPrism - static { - Skript.registerExpression(ExprRegularPolygon.class, Shape.class, ExpressionType.COMBINED, - "[a] [solid:(solid|filled)] regular polygon with %number% sides and radius %number%", - "[a] [solid:(solid|filled)] regular polygon with %number% sides and side length %number%", - "[a[n]] [solid:(solid|filled)] (3:[equilateral ]triangle|4:square|5:pentagon|6:hexagon|7:heptagon|8:octagon) with radius %number%", - "[a[n]] [solid:(solid|filled)] (3:[equilateral ]triangle|4:square|5:pentagon|6:hexagon|7:heptagon|8:octagon) with side length %number%" - ); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprRegularPolygon.class, Shape.class) + .supplier(ExprRegularPolygon::new) + .addPatterns( + "[a] [solid:(solid|filled)] regular polygon with %number% sides and radius %number%", + "[a] [solid:(solid|filled)] regular polygon with %number% sides and side length %number%", + "[a[n]] [solid:(solid|filled)] (3:[equilateral ]triangle|4:square|5:pentagon|6:hexagon|7:heptagon|8:octagon) with radius %number%", + "[a[n]] [solid:(solid|filled)] (3:[equilateral ]triangle|4:square|5:pentagon|6:hexagon|7:heptagon|8:octagon) with side length %number%" + ) + .build()); } private Expression radius; @@ -49,7 +53,8 @@ public class ExprRegularPolygon extends SimpleExpression { private int matchedPattern; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { style = parseResult.hasTag("solid") ? SamplingStyle.SURFACE : SamplingStyle.OUTLINE; this.matchedPattern = matchedPattern; switch (matchedPattern) { @@ -90,13 +95,11 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number sides = this.sides.getSingle(event); if (sides == null) return null; - // get radius, if radius is not specified, calculate it from side length Number radius; if (matchedPattern % 2 == 0) { radius = this.radius.getSingle(event); @@ -114,17 +117,7 @@ protected Shape[] get(Event event) { RegularPolygon shape = new RegularPolygon(sides.intValue(), radius.doubleValue()); shape.getPointSampler().setStyle(style); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override @@ -138,5 +131,4 @@ public String toString(@Nullable Event event, boolean debug) { default -> "regular polygon"; }; } - } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolyhedron.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolyhedron.java new file mode 100644 index 0000000..326a379 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprRegularPolyhedron.java @@ -0,0 +1,77 @@ +package com.sovdee.skriptparticles.skript.shapes.constructors; + +import ch.njol.skript.Skript; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.Literal; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.RegularPolyhedron; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.rendering.DrawData; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Particle Regular Polyhedron") +@Description(""" + Creates a regular polyhedron shape with the given radius. The radius must be greater than 0. + Valid polyhedra are tetrahedra (4 faces), octahedra (8), dodecahedra (12), and icosahedra (20). + + """) +@Example("set {_shape} to a tetrahedron with radius 1") +@Example("set {_shape} to a solid icosahedron with radius 2") +@Example("draw the shape of a tetrahedron with radius 5 at player") +@Since("1.0.0") +public class ExprRegularPolyhedron extends ShapeConstructorExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprRegularPolyhedron.class, Shape.class) + .supplier(ExprRegularPolyhedron::new) + .addPatterns("[a[n]] [outlined|:hollow|:solid] (:tetra|:octa|:icosa|:dodeca)hedron (with|of) radius %number%") + .build()); + } + + private Expression radius; + private int faces; + private SamplingStyle style; + + @Override + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] expressions, int matchedPattern, Kleenean kleenean, SkriptParser.ParseResult parseResult) { + radius = (Expression) expressions[0]; + faces = parseResult.hasTag("tetra") ? 4 : parseResult.hasTag("octa") ? 8 : parseResult.hasTag("dodeca") ? 12 : 20; + style = parseResult.hasTag("hollow") ? SamplingStyle.SURFACE : parseResult.hasTag("solid") ? SamplingStyle.FILL : SamplingStyle.OUTLINE; + + if (radius instanceof Literal literal && literal.getSingle().doubleValue() <= 0) { + Skript.error("The radius of the polyhedron must be greater than 0. (radius: " + + ((Literal) radius).getSingle().doubleValue() + ")"); + return false; + } + + return true; + } + + @Override + protected @Nullable List getShapes(Event event) { + Number r = radius.getSingle(event); + if (r == null) + return null; + RegularPolyhedron shape = new RegularPolyhedron(r.doubleValue(), faces); + shape.getPointSampler().setStyle(style); + shape.getPointSampler().setDrawContext(new DrawData()); + return List.of(shape); + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "regular polyhedron with " + faces + " faces with radius " + radius.toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphere.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphere.java similarity index 56% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphere.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphere.java index 7de571a..060e8c3 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphere.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphere.java @@ -1,46 +1,49 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.shapes.Sphere; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Sphere") -@Description({ - "Creates a sphere shape with the given radius. The radius must be greater than 0.", - "Default style is a hollow sphere, but you can use the 'solid' tag to make it solid." -}) -@Examples({ - "set {_shape} to sphere with radius 3", - "set {_shape} to solid sphere with radius 10" -}) +@Description(""" + Creates a sphere shape with the given radius. The radius must be greater than 0. + Default style is a hollow sphere, but you can use the 'solid' tag to make it solid. + """) +@Example("set {_shape} to sphere with radius 3") +@Example("set {_shape} to solid sphere with radius 10") @Since("1.0.0") -public class ExprSphere extends SimpleExpression { +public class ExprSphere extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprSphere.class, Shape.class, ExpressionType.COMBINED, "[a] [:solid] sphere (with|of) radius %number%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprSphere.class, Shape.class) + .supplier(ExprSphere::new) + .addPatterns("[a] [:solid] sphere (with|of) radius %number%") + .build()); } private Expression radius; private boolean isSolid; @Override - public boolean init(Expression[] exprs, int matchedPattern, @NotNull Kleenean isDelayed, @NotNull ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { radius = (Expression) exprs[0]; if (radius instanceof Literal literal && literal.getSingle().doubleValue() <= 0) { Skript.error("The radius of the sphere must be greater than 0. (radius: " + literal.getSingle().doubleValue() + ")"); @@ -51,7 +54,7 @@ public boolean init(Expression[] exprs, int matchedPattern, @NotNull Kleenean } @Override - protected Shape[] get(@NotNull Event event) { + protected @Nullable List getShapes(Event event) { Number radius = this.radius.getSingle(event); if (radius == null) return null; @@ -61,22 +64,10 @@ protected Shape[] get(@NotNull Event event) { Sphere shape = new Sphere(radius.doubleValue()); if (isSolid) shape.getPointSampler().setStyle(SamplingStyle.FILL); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - @NotNull - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override - @NotNull public String toString(@Nullable Event event, boolean debug) { return "sphere with radius " + radius.toString(event, debug); } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphericalCap.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphericalCap.java similarity index 61% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphericalCap.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphericalCap.java index 81f38a9..2bdc78e 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprSphericalCap.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprSphericalCap.java @@ -1,40 +1,42 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.shapes.SphericalCap; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Spherical Cap") -@Description({ - "Creates a spherical cap or spherical sector shape with the given radius and cutoff angle. The radius must be greater than 0.", - "The angle must be between 0 and 180 degrees. If the angle is 180 degrees, the shape will be a sphere.", - "A spherical cap is a portion of the surface of a sphere. A spherical sector, or spherical cone, is essentially a cone with a rounded base." -}) -@Examples({ - "set {_shape} to spherical cap with radius 10 and angle 45 degrees", - "set {_shape} to a spherical sector of radius 3 and angle 90 degrees" -}) +@Description(""" + Creates a spherical cap or spherical sector shape with the given radius and cutoff angle. The radius must be greater than 0. + The angle must be between 0 and 180 degrees. If the angle is 180 degrees, the shape will be a sphere. + A spherical cap is a portion of the surface of a sphere. A spherical sector, or spherical cone, is essentially a cone with a rounded base. + """) +@Example("set {_shape} to spherical cap with radius 10 and angle 45 degrees") +@Example("set {_shape} to a spherical sector of radius 3 and angle 90 degrees") @Since("1.0.0") -public class ExprSphericalCap extends SimpleExpression { +public class ExprSphericalCap extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprSphericalCap.class, Shape.class, ExpressionType.COMBINED, - "[a] spherical (cap|:sector) (with|of) radius %number% and [cutoff] angle [of] %number% [degrees|:radians]"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprSphericalCap.class, Shape.class) + .supplier(ExprSphericalCap::new) + .addPatterns("[a] spherical (cap|:sector) (with|of) radius %number% and [cutoff] angle [of] %number% [degrees|:radians]") + .build()); } private Expression radius; @@ -43,7 +45,8 @@ public class ExprSphericalCap extends SimpleExpression { private boolean isSector = false; @Override - public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { radius = (Expression) exprs[0]; angle = (Expression) exprs[1]; isRadians = parseResult.hasTag("radians"); @@ -66,8 +69,7 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number radius = this.radius.getSingle(event); Number angle = this.angle.getSingle(event); if (radius == null || angle == null) @@ -81,23 +83,11 @@ protected Shape[] get(Event event) { if (isSector) shape.getPointSampler().setStyle(SamplingStyle.FILL); shape.getPointSampler().setDrawContext(new DrawData()); - - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override public String toString(@Nullable Event event, boolean debug) { return "spherical " + (isSector ? "sector" : "cap") + " with radius " + radius.toString(event, debug) + " and angle " + angle.toString(event, debug) + (isRadians ? " radians" : " degrees"); } - } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprStar.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprStar.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprStar.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprStar.java index 4772343..0c438b8 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/constructors/ExprStar.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ExprStar.java @@ -1,36 +1,42 @@ -package com.sovdee.skriptparticles.elements.expressions.constructors; +package com.sovdee.skriptparticles.skript.shapes.constructors; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.Literal; import ch.njol.skript.lang.SkriptParser.ParseResult; -import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; -import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.sampling.SamplingStyle; +import com.sovdee.shapes.shapes.Shape; import com.sovdee.shapes.shapes.Star; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.MathUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; + +import java.util.List; +import org.skriptlang.skript.registration.SyntaxRegistry; @Name("Particle Star") -@Description({ - "Creates a star shape with the given number of points, inner radius, and outer radius. The number of points must be at least 2, and the inner and outer radii must be greater than 0.", - "Note that \"points\" in this context is referring to the tips of the star, not the number of particles." -}) -@Examples({ - "set {_shape} to star with 5 points, inner radius 1, and outer radius 2", - "draw the shape of a star with 4 points, inner radius 2, and outer radius 4 at player" -}) -public class ExprStar extends SimpleExpression { +@Description(""" + Creates a star shape with the given number of points, inner radius, and outer radius. + The number of points must be at least 2, and the inner and outer radii must be greater than 0. + Note that "points" in this context is referring to the tips of the star, not the number of particles. + """) +@Example("set {_shape} to star with 5 points, inner radius 1, and outer radius 2") +@Example("draw the shape of a star with 4 points, inner radius 2, and outer radius 4 at player") +@Since("1.0.1") +public class ExprStar extends ShapeConstructorExpression { - static { - Skript.registerExpression(ExprStar.class, Shape.class, ExpressionType.COMBINED, "[a] [:solid] star with %number% points(,| and) inner radius %number%[,] and outer radius %number%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprStar.class, Shape.class) + .supplier(ExprStar::new) + .addPatterns("[a] [:solid] star with %number% points(,| and) inner radius %number%[,] and outer radius %number%") + .build()); } private Expression points; @@ -39,7 +45,8 @@ public class ExprStar extends SimpleExpression { private boolean isSolid; @Override - public boolean init(Expression[] expressions, int i, Kleenean kleenean, ParseResult parseResult) { + @SuppressWarnings("unchecked") + public boolean initialize(Expression[] expressions, int matchedPattern, Kleenean kleenean, ParseResult parseResult) { points = (Expression) expressions[0]; innerRadius = (Expression) expressions[1]; outerRadius = (Expression) expressions[2]; @@ -65,8 +72,7 @@ public boolean init(Expression[] expressions, int i, Kleenean kleenean, Parse } @Override - @Nullable - protected Shape[] get(Event event) { + protected @Nullable List getShapes(Event event) { Number points = this.points.getSingle(event); Number innerRadius = this.innerRadius.getSingle(event); Number outerRadius = this.outerRadius.getSingle(event); @@ -81,21 +87,11 @@ protected Shape[] get(Event event) { if (isSolid) shape.getPointSampler().setStyle(SamplingStyle.SURFACE); shape.getPointSampler().setDrawContext(new DrawData()); - return new Shape[]{shape}; - } - - @Override - public boolean isSingle() { - return true; - } - - @Override - public Class getReturnType() { - return Shape.class; + return List.of(shape); } @Override - public String toString(@Nullable Event event, boolean b) { - return "a " + (isSolid ? "solid " : "") + "star shape with " + points.toString(event, b) + " points, inner radius " + innerRadius.toString(event, b) + ", and outer radius " + outerRadius.toString(event, b); + public String toString(@Nullable Event event, boolean debug) { + return "a " + (isSolid ? "solid " : "") + "star shape with " + points.toString(event, debug) + " points, inner radius " + innerRadius.toString(event, debug) + ", and outer radius " + outerRadius.toString(event, debug); } } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ShapeConstructorExpression.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ShapeConstructorExpression.java new file mode 100644 index 0000000..b7205ff --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/constructors/ShapeConstructorExpression.java @@ -0,0 +1,100 @@ +package com.sovdee.skriptparticles.skript.shapes.constructors; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.expressions.base.SectionExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.TriggerItem; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.util.SkriptUtil; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/** + * Base class for all the shape constructors + */ +public abstract class ShapeConstructorExpression extends SectionExpression { + + private boolean hasSection = false; + private final ThreadLocal threadShape = new ThreadLocal<>(); + + @Override + public boolean init( + Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult parseResult, + @Nullable SectionNode sectionNode, @Nullable List list + ) { + if (!initialize(expressions, matchedPattern, hasDelayBefore, parseResult)) + return false; + + if (SkriptUtil.getCurrentSectionExpression(ShapeConstructorExpression.class, getParser()) != null) { + Skript.error("Shapes cannot be constructed within other shape's constructor sections!"); + return false; + } + + if (sectionNode != null) { + getParser().setHasDelayBefore(Kleenean.FALSE); + loadCode(sectionNode); + if (!getParser().getHasDelayBefore().isFalse()) { + Skript.error("Delays can't be used within a shape creation section"); + return false; + } + getParser().setHasDelayBefore(hasDelayBefore); + hasSection = true; + } + return true; + } + + protected abstract boolean initialize(Expression[] expressions, int matchedPattern, Kleenean hasDelayBefore, ParseResult parseResult); + + @Override + protected Shape @Nullable [] get(Event event) { + List shapes; + try { + shapes = getShapes(event); + } catch (IllegalArgumentException e) { + // the shapes library rejects impossible shapes, such as a line between two identical points + error("Could not create a shape: " + e.getMessage()); + return new Shape[0]; + } + if (shapes == null) + return new Shape[0]; + // run section code to modify shapes + if (hasSection) { + for (Shape shape : shapes) { + threadShape.set(shape); + runSection(event); + } + threadShape.remove(); + } + return shapes.toArray(new Shape[0]); + } + + /** + * Returns the initially constructed shapes for modification by the section, if it exists. + * @param event The event context. + * @return The created shape. + */ + protected abstract @Nullable List getShapes(Event event); + + /** + * @return The shape currently being constructed by this syntax on this thread. + */ + public Shape getCurrentShape() { + return threadShape.get(); + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return Shape.class; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffRotateShape.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffRotateShape.java similarity index 68% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffRotateShape.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffRotateShape.java index 031b798..492b647 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffRotateShape.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffRotateShape.java @@ -1,15 +1,18 @@ -package com.sovdee.skriptparticles.elements.effects; +package com.sovdee.skriptparticles.skript.shapes.effects; import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Effect; import ch.njol.skript.lang.Expression; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.util.Kleenean; -import com.sovdee.skriptparticles.elements.sections.DrawShapeEffectSection.DrawEvent; +import com.sovdee.skriptparticles.skript.drawing.sections.DrawShapeEffectSection; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; +import com.sovdee.skriptparticles.skript.drawing.sections.DrawShapeEffectSection.DrawEvent; import com.sovdee.shapes.shapes.Shape; import org.bukkit.event.Event; import org.bukkit.util.Vector; @@ -17,6 +20,13 @@ import org.joml.Quaterniond; import org.joml.Quaternionf; +/** + * Skript effect that rotates one or more shapes around an axis by an angle, or applies a quaternion rotation. + * Supports named cardinal axes (x, y, z), arbitrary vector axes, local (relative) rotation, and radians/degrees. + * When used inside a {@link DrawShapeEffectSection.DrawEvent DrawEvent} + * section without an explicit shape argument, it operates on the currently-being-drawn shape. + * See {@code @Name}, {@code @Description}, {@code @Example}, and {@code @Since} for Skript-facing documentation. + */ @Name("Rotate Shape") @Description({ "Rotates shapes around a given axis by a given angle. The axis can be specified as a vector or as a single axis (x, y, or z). " + @@ -24,21 +34,22 @@ "The axis can also be specified as a local or relative axis, which rotate relative to the shape's current rotation.", "For example, you can rotate a shape around the global y axis, or around the shape's local y axis." }) -@Examples({ - "rotate shape {_shape} around x axis by 90 degrees", - "rotate shape {_shape} around vector(0, 1, 0) by 1.4 radians", - "rotate shape {_shape} around local y axis by 90" -}) +@Example("rotate shape {_shape} around x axis by 90 degrees") +@Example("rotate shape {_shape} around vector(0, 1, 0) by 1.4 radians") +@Example("rotate shape {_shape} around local y axis by 90") @Since("1.0.0") public class EffRotateShape extends Effect { - static { - Skript.registerEffect(EffRotateShape.class, - "rotate shape[s] %shapes% around [relative:(relative|local)] (v:%-vector%|((:x|:y|:z)(-| )axis)) by %number% [degrees|:radians]", - "rotate shape[s] %shapes% (by|with) [rotation] %quaternion%", - "rotate [drawn] shape[s] around [relative:(relative|local)] (v:%-vector%|((:x|:y|:z)(-| )axis)) by %number% [degrees|:radians]", - "rotate [drawn] shape[s] (by|with) [rotation] %quaternion%" - ); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EFFECT, SyntaxInfo.builder(EffRotateShape.class) + .supplier(EffRotateShape::new) + .addPatterns( + "rotate shape[s] %shapes% around [relative:(relative|local)] (v:%-vector%|((:x|:y|:z)(-| )axis)) by %number% [degrees|:radians]", + "rotate shape[s] %shapes% (by|with) [rotation] %quaternion%", + "rotate [drawn] shape[s] around [relative:(relative|local)] (v:%-vector%|((:x|:y|:z)(-| )axis)) by %number% [degrees|:radians]", + "rotate [drawn] shape[s] (by|with) [rotation] %quaternion%" + ) + .build()); } private Expression shapes; @@ -51,6 +62,10 @@ public class EffRotateShape extends Effect { private boolean isRadians = false; private boolean isAxisAngle = false; + /** + * Determines whether drawn-shapes or explicit shapes are used, resolves axis/quaternion mode, + * and validates that the effect is inside a DrawEvent section when no explicit shape is given. + */ @Override public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { int offset = 0; @@ -69,10 +84,10 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye return true; } isAxisAngle = true; + relative = parseResult.hasTag("relative"); if (parseResult.hasTag("v")) { vectorAxis = (Expression) exprs[1 - offset]; } else { - relative = parseResult.hasTag("relative"); axis = parseResult.hasTag("x") ? "x" : parseResult.hasTag("y") ? "y" : "z"; } angle = (Expression) exprs[2 - offset]; @@ -80,6 +95,10 @@ public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelaye return true; } + /** + * Applies the rotation to each target shape; uses premul for global rotations and mul for + * relative/local ones. + */ @Override protected void execute(Event event) { Quaterniond rotation; @@ -130,6 +149,9 @@ protected void execute(Event event) { } } + /** + * {@inheritDoc} + */ @Override public String toString(@Nullable Event event, boolean debug) { if (rotation != null) diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffSetOrdering.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffSetOrdering.java new file mode 100644 index 0000000..b50b2ca --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffSetOrdering.java @@ -0,0 +1,96 @@ +package com.sovdee.skriptparticles.skript.shapes.effects; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Effect; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.shapes.sampling.PointOrder; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.skript.shaders.ModifierInputs; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector3d; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +import java.util.Comparator; + +/** + * Skript effect that sets the order in which a shape's points are produced, and therefore the order in which + * an animated draw reveals them and the order the draw order input follows. + */ +@Name("Shape Draw Order") +@Description({ + "Sets the order in which a shape's points are drawn. This is the order an animated draw reveals them in, " + + "and the order the 'draw order' input of modifiers follows.", + "Points can be ordered along an input: the x, y or z axis, the radius (distance from the shape's y axis), " + + "the spherical distance (distance from the shape's center), or the angle " + + "(counterclockwise around the y axis, starting at +x). They go from where the input is lowest to where " + + "it is highest, unless reversed. The order follows the shape as it is rotated.", + "lowest-to-highest goes from the -x -y -z corner of the shape to the +x +y +z corner, and highest-to-lowest the other way.", + "random shuffles the points, but the same way each time, so animations don't flicker.", + "default draws the points in the order the shape generates them." +}) +@Example("set the draw order of {_helix} to y axis") +@Example("set the animation order of {_sphere} to reversed spherical distance") +@Example("set the draw order of {_circle} to angle") +@Example("set the drawing order of {_cube} to random") +@Example("set the animation order of {_cube} to lowest-to-highest") +@Example("set the draw order of {_shape} to default") +@Since("1.3.0") +public class EffSetOrdering extends Effect { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EFFECT, SyntaxInfo.builder(EffSetOrdering.class) + .supplier(EffSetOrdering::new) + .addPatterns("set [the] (animation|draw[ing]) order of %shapes% to (default|:random" + + "|lowest:lowest-to-highest|highest:highest-to-lowest|[:reversed] " + ModifierInputs.SPATIAL_INPUTS + ")") + .build()); + } + + private Expression shapes; + private @Nullable Comparator ordering; + private String orderName; + + @Override + @SuppressWarnings("unchecked") + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + shapes = (Expression) expressions[0]; + StandardInput input = ModifierInputs.fromTags(parseResult); + if (input != null) { + boolean reversed = parseResult.hasTag("reversed"); + ordering = reversed ? PointOrder.along(input).reversed() : PointOrder.along(input); + orderName = (reversed ? "reversed " : "") + ModifierInputs.toString(input); + } else if (parseResult.hasTag("random")) { + ordering = PointOrder.RANDOM; + orderName = "random"; + } else if (parseResult.hasTag("lowest")) { + ordering = PointOrder.DIAGONAL; + orderName = "lowest-to-highest"; + } else if (parseResult.hasTag("highest")) { + ordering = PointOrder.DIAGONAL.reversed(); + orderName = "highest-to-lowest"; + } else { + ordering = null; + orderName = "default"; + } + return true; + } + + @Override + protected void execute(Event event) { + for (Shape shape : shapes.getArray(event)) { + shape.getPointSampler().setOrdering(ordering); + } + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "set the draw order of " + shapes.toString(event, debug) + " to " + orderName; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffToggleAxes.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffToggleAxes.java similarity index 59% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffToggleAxes.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffToggleAxes.java index b8026f5..ce80a8d 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/effects/EffToggleAxes.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/effects/EffToggleAxes.java @@ -1,36 +1,43 @@ -package com.sovdee.skriptparticles.elements.effects; +package com.sovdee.skriptparticles.skript.shapes.effects; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Effect; import ch.njol.skript.lang.Expression; import ch.njol.skript.lang.SkriptParser; import ch.njol.util.Kleenean; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.shapes.DrawData; +import com.sovdee.skriptparticles.rendering.DrawData; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +/** + * Skript effect that shows or hides the local and/or global debug axes of one or more shapes. + * Intended for debugging purposes; toggles the corresponding flag on each shape's {@link DrawData DrawData}. + * Documented from the Skript side via {@code @Name}, {@code @Description}, {@code @Example}, and {@code @Since}. + */ @Name("Toggle Axes") @Description({ "Toggles the visibility of the local and/or global axes of a shape.", "When on, the shape will also draw its local and/or global axes when drawn.", "This is intended for debugging purposes." }) -@Examples({ - "show local axes of {_shape}", - "hide global axes of {_shape}", - "hide local and global axes of {_shape}", - "show local axes of {_shape} and {_shape2}" -}) +@Example("show local axes of {_shape}") +@Example("hide global axes of {_shape}") +@Example("hide local and global axes of {_shape}") +@Example("show local axes of {_shape} and {_shape2}") @Since("1.0.0") public class EffToggleAxes extends Effect { - static { - Skript.registerEffect(EffToggleAxes.class, "(:show|:hide) [:local] [and] [:global] axes of [shape[s]] %shapes%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EFFECT, SyntaxInfo.builder(EffToggleAxes.class) + .supplier(EffToggleAxes::new) + .addPatterns("(:show|:hide) [:local] [and] [:global] axes of [shape[s]] %shapes%") + .build()); } private Expression shape; @@ -38,6 +45,9 @@ public class EffToggleAxes extends Effect { private boolean globalFlag = false; private boolean showFlag = false; + /** + * {@inheritDoc} + */ @Override public boolean init(Expression[] expressions, int i, Kleenean kleenean, SkriptParser.ParseResult parseResult) { shape = (Expression) expressions[0]; @@ -47,6 +57,9 @@ public boolean init(Expression[] expressions, int i, Kleenean kleenean, Skrip return true; } + /** + * Applies the show/hide flags to the local and/or global axes of each shape's DrawData. + */ @Override protected void execute(Event event) { Shape[] shapes = shape.getArray(event); @@ -59,6 +72,9 @@ protected void execute(Event event) { } } + /** + * {@inheritDoc} + */ @Override public String toString(@Nullable Event event, boolean debug) { return (showFlag ? "show" : "hide") + (globalFlag ? "global" : "") + (globalFlag && localFlag ? " and " : "") + diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprCurrentShape.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprCurrentShape.java new file mode 100644 index 0000000..6115c97 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprCurrentShape.java @@ -0,0 +1,74 @@ +package com.sovdee.skriptparticles.skript.shapes.expressions; + +import ch.njol.skript.Skript; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.skript.drawing.expressions.ExprDrawnShapes; +import com.sovdee.skriptparticles.skript.drawing.sections.DrawShapeEffectSection; +import com.sovdee.skriptparticles.skript.shapes.constructors.ShapeConstructorExpression; +import com.sovdee.skriptparticles.util.SkriptUtil; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; + +public class ExprCurrentShape extends SimpleExpression { + + public static void register(SyntaxRegistry registry) { + registry.register( + SyntaxRegistry.EXPRESSION, + SyntaxInfo.Expression.builder(ExprCurrentShape.class, Shape.class) + .addPattern("[the] current shape") + .supplier(ExprCurrentShape::new) + .build()); + } + + private ShapeConstructorExpression section; + private ExprDrawnShapes eventExpr; + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + section = SkriptUtil.getCurrentSectionExpression(ShapeConstructorExpression.class, getParser()); + if (section == null) { + if (getParser().isCurrentEvent(DrawShapeEffectSection.DrawEvent.class)) { + eventExpr = new ExprDrawnShapes(); + } else { + Skript.error("The 'current shape' expression can only be used inside of a shape construction section. Use 'drawn shape' for draw shape sections."); + return false; + } + } + return true; + } + + @Override + protected Shape @Nullable [] get(Event event) { + Shape shape; + if (section != null) { + shape = section.getCurrentShape(); + } else { + shape = eventExpr.getSingle(event); + } + if (shape == null) + return new Shape[0]; + return new Shape[]{shape}; + } + + @Override + public boolean isSingle() { + return true; + } + + @Override + public Class getReturnType() { + return Shape.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "the current shape"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprRotation.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprRotation.java similarity index 67% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprRotation.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprRotation.java index a270f41..1a7187a 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprRotation.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprRotation.java @@ -1,36 +1,44 @@ -package com.sovdee.skriptparticles.elements.expressions; +package com.sovdee.skriptparticles.skript.shapes.expressions; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.SkriptParser; import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.skriptparticles.util.Quaternion; import org.bukkit.event.Event; import org.bukkit.util.Vector; import org.jetbrains.annotations.Nullable; +/** + * Skript expression that constructs a {@link com.sovdee.skriptparticles.util.Quaternion} representing a rotation. + * Supports two forms: axis-angle (around a vector by a given angle in degrees or radians) and + * vector-to-vector (the shortest rotation from one direction to another). + * Documented from the Skript side via {@code @Name}, {@code @Description}, {@code @Example}, and {@code @Since}. + */ @Name("Rotation") @Description("Describes a rotation around a vector by a given angle, or from one vector to another. An alternative to the `axisAngle` and `quaternion` functions. Returns a quaternion.") -@Examples({ - "set {_rotation} to rotation around vector(1, 0, 0) by an angle of 90 degrees", - "set {_rotation} to a rotation around vector(1, 1, 1) by 1.57 radians", - "set {_rotation} to a rotation from vector(1, 0, 0) to vector(0, 1, 0)", - "set {_rotation} to a rotation between vector(1, 0, 1) and vector(0, 1, 2)" -}) +@Example("set {_rotation} to rotation around vector(1, 0, 0) by an angle of 90 degrees") +@Example("set {_rotation} to a rotation around vector(1, 1, 1) by 1.57 radians") +@Example("set {_rotation} to a rotation from vector(1, 0, 0) to vector(0, 1, 0)") +@Example("set {_rotation} to a rotation between vector(1, 0, 1) and vector(0, 1, 2)") @Since("1.0.0") public class ExprRotation extends SimpleExpression { - static { - Skript.registerExpression(ExprRotation.class, Quaternion.class, ExpressionType.COMBINED, - "[the|a] rotation (from|around) [the] [vector] %vector% (with|by) [[the|an] angle [of]] %number% [degrees|:radians]", - "[the|a] rotation (from|between) %vector% (to|and) %vector%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprRotation.class, Quaternion.class) + .supplier(ExprRotation::new) + .addPatterns( + "[the|a] rotation (from|around) [the] [vector] %vector% (with|by) [[the|an] angle [of]] %number% [degrees|:radians]", + "[the|a] rotation (from|between) %vector% (to|and) %vector%" + ) + .build()); } @Nullable diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprShapeCopy.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprShapeCopy.java similarity index 66% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprShapeCopy.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprShapeCopy.java index a85e9a1..4fb57ba 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/ExprShapeCopy.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/expressions/ExprShapeCopy.java @@ -1,18 +1,18 @@ -package com.sovdee.skriptparticles.elements.expressions; +package com.sovdee.skriptparticles.skript.shapes.expressions; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.Shape; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.NonNull; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; @@ -20,15 +20,17 @@ @Name("Shape Copy") @Description("Returns a copy of the given shape. This is useful if you want a modified version of a shape without changing the original.") -@Examples({ - "set {_shape-2} to a copy of {_shape}", - "set {_shape} to a copy of a sphere with radius 1" -}) +@Example("set {_shape-2} to a shape copy of {_shape}") +@Example("set {_shapes::*} to shape copies of {_shapes::*}") @Since("1.0.0") public class ExprShapeCopy extends SimpleExpression { - static { - Skript.registerExpression(ExprShapeCopy.class, Shape.class, ExpressionType.SIMPLE, "[a] shape cop(y|ies) of %shapes%"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprShapeCopy.class, Shape.class) + .supplier(ExprShapeCopy::new) + .addPatterns("[a] shape cop(y|ies) of %shapes%") + .priority(SyntaxInfo.SIMPLE) + .build()); } private Expression shapeExpr; @@ -40,7 +42,7 @@ public boolean init(Expression[] expressions, int matchedPattern, Kleenean kl } @Override - protected Shape[] get(@NonNull Event event) { + protected Shape[] get(@NotNull Event event) { Shape[] shape = shapeExpr.getArray(event); if (shape.length == 0) diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprHelixWindingRate.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprHelixWindingRate.java similarity index 75% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprHelixWindingRate.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprHelixWindingRate.java index d1a501c..de814ee 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprHelixWindingRate.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprHelixWindingRate.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Helix; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.shapes.shapes.Shape; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -16,17 +18,20 @@ "Gets or sets the winding rate of a helix. The winding rate is the number of times the helix wraps around the axis per block.", "The default winding rate is 1 loop per block, which delete or reset will set it to. The winding rate must be positive and non-zero." }) -@Examples({ - "set {_helix} to helix with radius 5, height 10, and winding rate 2 loops per block", - "set winding rate of {_helix} to 1", - "set winding rate of {_helix} to 1/10", - "set winding rate of {_helix} to 10.4" -}) +@Example(""" + set {_helix} to helix with radius 5, height 10, and winding rate 2 loops per block + set winding rate of {_helix} to 1 + set winding rate of {_helix} to 1/10 + set winding rate of {_helix} to 10.4 + """) @Since("1.0.0") public class ExprHelixWindingRate extends SimplePropertyExpression { - static { - register(ExprHelixWindingRate.class, Number.class, "winding rate", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprHelixWindingRate.class, Number.class, "winding rate", "shapes", false) + .supplier(ExprHelixWindingRate::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeBounds.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeBounds.java new file mode 100644 index 0000000..d759c06 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeBounds.java @@ -0,0 +1,88 @@ +package com.sovdee.skriptparticles.skript.shapes.properties; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser.ParseResult; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.sampling.PointLimitExceededException; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.shapes.Shape; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Shape Bounds") +@Description({ + "The box that contains every point of a shape, as drawn: after its rotation, scale and offset.", + "The minimum and maximum corners are relative to the location the shape is drawn at, " + + "and the size is the distance between them along each axis.", + "These are measured from the points that are actually drawn, so they can change slightly with the particle density." +}) +@Example("set {_low} to {_location} ~ shape minimum corner of {_shape}") +@Example("set {_high} to {_location} ~ shape maximum corner of {_shape}") +@Example(""" + set {_size} to shape size of {_shape} + send "The shape is %x component of {_size}% by %y component of {_size}% by %z component of {_size}% blocks" + """) +@Since("2.0.0") +public class ExprShapeBounds extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeBounds.class, Vector.class, + "shape (:min|:max)[imum] corner", "shapes", false) + .supplier(ExprShapeBounds::new) + .build()); + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeBounds.class, Vector.class, + "shape (size|extent)", "shapes", false) + .supplier(ExprShapeBounds::new) + .build()); + } + + private enum Part { MIN, MAX, SIZE } + + private Part part; + + @Override + public boolean init(Expression[] expressions, int matchedPattern, Kleenean isDelayed, ParseResult parseResult) { + part = parseResult.hasTag("min") ? Part.MIN : parseResult.hasTag("max") ? Part.MAX : Part.SIZE; + return super.init(expressions, matchedPattern, isDelayed, parseResult); + } + + @Override + public @Nullable Vector convert(Shape shape) { + PointSampler sampler = shape.getPointSampler(); + ShapeBounds bounds; + try { + bounds = sampler.boundsOf(sampler.getPoints(shape)); + } catch (PointLimitExceededException e) { + return null; + } + return switch (part) { + case MIN -> new Vector(bounds.minX(), bounds.minY(), bounds.minZ()); + case MAX -> new Vector(bounds.maxX(), bounds.maxY(), bounds.maxZ()); + case SIZE -> new Vector(bounds.width(), bounds.height(), bounds.length()); + }; + } + + @Override + public Class getReturnType() { + return Vector.class; + } + + @Override + protected String getPropertyName() { + return switch (part) { + case MIN -> "shape minimum corner"; + case MAX -> "shape maximum corner"; + case SIZE -> "shape size"; + }; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeCutoffAngle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeCutoffAngle.java similarity index 73% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeCutoffAngle.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeCutoffAngle.java index 30b1373..aaa392a 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeCutoffAngle.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeCutoffAngle.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.CutoffShape; +import org.skriptlang.skript.registration.SyntaxRegistry; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -16,21 +18,23 @@ "For example, a cutoff angle of 90 on an arc will create a quarter of a circle.", "Note that an arc can range from 0 to 360 degrees, while a spherical cap can range from 0 to 180 degrees." }) -@Examples({ - "set {_shape}'s cutoff angle to 90", - "set cutoff angle of {_shape} to 180", - "reset {_shape}'s cutoff angle" -}) +@Example("set {_shape}'s cutoff angle to 90") +@Example("set cutoff angle of {_shape} to 180") +@Example("reset {_shape}'s cutoff angle") @Since("1.0.0") public class ExprShapeCutoffAngle extends SimplePropertyExpression { - static { - register(ExprShapeCutoffAngle.class, Number.class, "cutoff angle", "cutoffshapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeCutoffAngle.class, Number.class, "cutoff angle", "cutoffshapes", false) + .supplier(ExprShapeCutoffAngle::new) + .build()); } @Override public @Nullable Number convert(CutoffShape shape) { - return shape.getCutoffAngle(); + // degrees, like the values this expression accepts when changed + return Math.toDegrees(shape.getCutoffAngle()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLWH.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLWH.java similarity index 79% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLWH.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLWH.java index f9b2d3d..ffe34f0 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLWH.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLWH.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import ch.njol.skript.lang.Expression; +import org.skriptlang.skript.registration.SyntaxRegistry; import ch.njol.skript.lang.SkriptParser; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.LWHShape; @@ -17,19 +19,20 @@ @Description({ "The length, width, or height of a shape. Changing this will change the size of the shape. Resetting or deleting it will set it back to the default value of 1." }) -@Examples({ - "set {_shape}'s shape length to 5", - "set {_shape}'s shape width to 5", - "set {_shape}'s shape height to 5", - "reset {_shape}'s shape length", - "reset {_shape}'s shape width", - "add 6 to {_shape}'s shape height" -}) +@Example("set {_shape}'s shape length to 5") +@Example("set {_shape}'s shape width to 5") +@Example("set {_shape}'s shape height to 5") +@Example("reset {_shape}'s shape length") +@Example("reset {_shape}'s shape width") +@Example("add 6 to {_shape}'s shape height") @Since("1.0.0") public class ExprShapeLWH extends SimplePropertyExpression { - static { - register(ExprShapeLWH.class, Number.class, "shape (:length|:width|:height)", "lwhshapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeLWH.class, Number.class, "shape (:length|:width|:height)", "lwhshapes", false) + .supplier(ExprShapeLWH::new) + .build()); } private int lwh; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLocations.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLocations.java similarity index 65% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLocations.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLocations.java index 72b56f2..fed010f 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeLocations.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeLocations.java @@ -1,13 +1,13 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; -import ch.njol.skript.Skript; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.lang.Expression; -import ch.njol.skript.lang.ExpressionType; import ch.njol.skript.lang.SkriptParser; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; import ch.njol.skript.lang.util.SimpleExpression; import ch.njol.skript.util.Direction; import ch.njol.util.Kleenean; @@ -15,8 +15,8 @@ import com.sovdee.skriptparticles.util.VectorConversion; import org.bukkit.Location; import org.bukkit.event.Event; -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; @@ -25,18 +25,22 @@ "Returns all the locations that particles would be spawned at if the shape was centered at the given location.", "If you want the vectors relative to the center of the shape, use the 'shape points' expression." }) -@Examples({ - "set {_locations::*} to point locations of (circle of radius 10) centered at player", - "teleport player to random element of {_locations::*}", - "", - "# drawing the shape yourself: (skbee particle syntax)", - "draw 1 red dust at (particle locations of (circle of radius 10) centered at player)" -}) +@Example(""" + set {_locations::*} to point locations of (circle of radius 10) centered at player + teleport player to random element of {_locations::*} + """) +@Example(""" + # drawing the shape yourself + draw red dust particle at (particle locations of (circle of radius 10) centered at player) + """) @Since("1.0.0") public class ExprShapeLocations extends SimpleExpression { - static { - Skript.registerExpression(ExprShapeLocations.class, Location.class, ExpressionType.COMBINED, "(particle|point) locations of %shapes% [[centered] %direction% %location%]"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprShapeLocations.class, Location.class) + .supplier(ExprShapeLocations::new) + .addPatterns("(particle|point) locations of %shapes% [[centered] %direction% %location%]") + .build()); } private Expression shapeExpr; @@ -73,12 +77,12 @@ public boolean isSingle() { } @Override - public @NonNull Class getReturnType() { + public @NotNull Class getReturnType() { return Location.class; } @Override - public @NonNull String toString(@Nullable Event event, boolean debug) { + public @NotNull String toString(@Nullable Event event, boolean debug) { return "Locations of shapes"; } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeModifiers.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeModifiers.java new file mode 100644 index 0000000..74dbd72 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeModifiers.java @@ -0,0 +1,148 @@ +package com.sovdee.skriptparticles.skript.shapes.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import org.skriptlang.skript.registration.SyntaxInfo; +import org.skriptlang.skript.registration.SyntaxRegistry; +import ch.njol.skript.lang.util.SimpleExpression; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.shapes.Shape; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +@Name("Shape Modifiers") +@Description({ + "The modifiers of a shape. Modifiers transform geometry (scale, twist, wave) or render properties (gradients, motion).", + "Geometry modifiers are cached; render modifiers run per-frame.", + "SET replaces all modifiers. ADD appends a modifier. REMOVE removes a specific modifier. DELETE/RESET clears all modifiers." +}) +@Example("add scale from 1 to 0 to modifiers of {_shape}") +@Example("add a twist of 90 degrees to modifiers of {_helix}") +@Example("add gradient from red to blue along the y axis to modifiers of {_shape}") +@Example("add motion modifier counterclockwise to modifiers of {_shape}") +@Example("set modifiers of {_shape} to gradient from red to blue along the y axis") +@Example("delete modifiers of {_shape}") +@Since("2.0.0") +@SuppressWarnings("rawtypes") +public class ExprShapeModifiers extends SimpleExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, SyntaxInfo.Expression.builder(ExprShapeModifiers.class, PointModifier.class) + .supplier(ExprShapeModifiers::new) + .addPatterns( + "[point] modifiers of %shapes%", + "%shapes%'[s] [point] modifiers" + ) + .build()); + } + + private Expression shapes; + + @SuppressWarnings("unchecked") + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { + this.shapes = (Expression) exprs[0]; + return true; + } + + @Override + protected PointModifier @Nullable [] get(Event event) { + Shape[] shapeArr = shapes.getArray(event); + if (shapeArr.length == 0) return new PointModifier[0]; + List> mods = new ArrayList<>(); + for (Shape shape : shapeArr) { + mods.addAll(shape.getPointSampler().getModifiers()); + } + return mods.toArray(new PointModifier[0]); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + // an array type, so that several modifiers can be given at once + case SET, REMOVE, ADD -> new Class[]{PointModifier[].class}; + case DELETE -> new Class[0]; + default -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + Shape[] shapeArr = shapes.getArray(event); + switch (mode) { + case SET -> { + if (delta == null) return; + for (Shape shape : shapeArr) { + shape.getPointSampler().clearModifiers(); + for (Object o : delta) { + if (o instanceof PointModifier mod) + shape.getPointSampler().addModifier(mod.clone()); + } + } + } + case ADD -> { + if (delta == null) return; + for (Shape shape : shapeArr) { + for (Object o : delta) { + // each shape gets its own copy, as with set + if (o instanceof PointModifier mod) + shape.getPointSampler().addModifier(mod.clone()); + } + } + } + case REMOVE -> { + if (delta == null) return; + for (Shape shape : shapeArr) { + for (Object o : delta) { + if (o instanceof PointModifier mod) + removeMatching(shape, mod); + } + } + } + case DELETE, RESET -> { + for (Shape shape : shapeArr) + shape.getPointSampler().clearModifiers(); + } + } + } + + /** + * Removes the first modifier of a shape that is equivalent to the given one. + * Shapes hold their own copies of the modifiers they are given, so the match is by type and settings + * rather than by identity. + */ + private static void removeMatching(Shape shape, PointModifier modifier) { + for (PointModifier existing : shape.getPointSampler().getModifiers()) { + if (existing == modifier + || (existing.getClass() == modifier.getClass() && existing.modifierHash() == modifier.modifierHash())) { + shape.getPointSampler().removeModifier(existing); + return; + } + } + } + + @Override + public boolean isSingle() { + return false; + } + + @Override + @SuppressWarnings("rawtypes") + public Class getReturnType() { + return PointModifier.class; + } + + @Override + public String toString(@Nullable Event event, boolean debug) { + return "modifiers of " + shapes.toString(event, debug); + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeNormal.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeNormal.java similarity index 80% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeNormal.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeNormal.java index 96f4b97..a9add4a 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeNormal.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeNormal.java @@ -1,13 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Shape; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.skriptparticles.util.Quaternion; import com.sovdee.skriptparticles.util.VectorConversion; import org.joml.Quaterniond; @@ -21,16 +22,17 @@ "If the shape is rotated, this will be the vector that is pointing up after the rotation. Treat it as what the shape thinks is \"up\".", "Changing this will rotate the shape accordingly. Resetting or deleting it will set it back to the default orientation.", }) -@Examples({ - "set {_shape}'s normal vector to vector(1, 0, 0)", - "set {_v} to {_shape}'s normal", - "reset {_shape}'s normal vector" -}) +@Example("set {_shape}'s normal vector to vector(1, 0, 0)") +@Example("set {_v} to {_shape}'s normal") +@Example("reset {_shape}'s normal vector") @Since("1.0.0") public class ExprShapeNormal extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeNormal.class, Vector.class, "normal [vector]", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeNormal.class, Vector.class, "normal [vector]", "shapes", false) + .supplier(ExprShapeNormal::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOffset.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOffset.java similarity index 77% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOffset.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOffset.java index d92597c..d5d75d5 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOffset.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOffset.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Shape; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.skriptparticles.util.VectorConversion; import org.bukkit.event.Event; import org.bukkit.util.Vector; @@ -19,15 +21,17 @@ "Returns the offset vector of a shape. This is the vector that is added to the location of the shape before drawing.", "Changing this will move the shape accordingly. Resetting or deleting it will set it back to the default offset of (0, 0, 0)." }) -@Examples({ - "set {_shape}'s offset vector to vector(1, 0, 0)", - "set offset of {_shape} to vector(0, 10, 0)", - "reset {_shape}'s offset vector" -}) +@Example("set {_shape}'s offset vector to vector(1, 0, 0)") +@Example("set offset of {_shape} to vector(0, 10, 0)") +@Example("reset {_shape}'s offset vector") +@Since("1.0.0") public class ExprShapeOffset extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeOffset.class, Vector.class, "offset [vector]", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeOffset.class, Vector.class, "offset [vector]", "shapes", false) + .supplier(ExprShapeOffset::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOrientation.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOrientation.java similarity index 77% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOrientation.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOrientation.java index 4eaeda5..e0dcc79 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeOrientation.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeOrientation.java @@ -1,13 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Shape; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.skriptparticles.util.Quaternion; import org.joml.Quaterniond; import org.bukkit.event.Event; @@ -20,17 +21,18 @@ "Changing this will rotate the shape accordingly. Resetting or deleting it will set it back to the default orientation of (0, 0, 0, 1).", "The expression returns a quaternion. You can use the quaternion and axis angle functions or the rotation expression to create an orientation." }) -@Examples({ - "set {_shape}'s orientation to quaternion(0, 0, 0, 1)", - "set {_shape}'s orientation to axisAngle(90, 0, 1, 0)", - "set {_shape}'s orientation to {_shape2}'s orientation", - "reset {_shape}'s orientation" -}) +@Example("set {_shape}'s orientation to quaternion(1, 0, 0, 0)") +@Example("set {_shape}'s orientation to axisAngle(90, vector(0, 1, 0))") +@Example("set {_shape}'s orientation to {_shape2}'s orientation") +@Example("reset {_shape}'s orientation") @Since("1.0.0") public class ExprShapeOrientation extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeOrientation.class, Quaternion.class, "orientation", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeOrientation.class, Quaternion.class, "orientation", "shapes", false) + .supplier(ExprShapeOrientation::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticle.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticle.java similarity index 54% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticle.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticle.java index 458dd38..b650969 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeParticle.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticle.java @@ -1,46 +1,44 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Shape; -import com.sovdee.skriptparticles.particles.Particle; -import com.sovdee.skriptparticles.shapes.DrawData; +import org.skriptlang.skript.registration.SyntaxRegistry; +import com.sovdee.skriptparticles.rendering.DrawData; import com.sovdee.skriptparticles.util.ParticleUtil; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; @Name("Shape Particle") @Description({ "The particle of a shape. This is the particle that the shape uses to draw itself.", "Changing this will change the particle of the shape. Resetting or deleting it will set it back to the default particle (flame).", - "You can set the particle to any base or custom particle." -}) -@Examples({ - "set {_shape}'s particle to flame particle", - "set {_shape}'s particle to 1 of electric spark particle with extra 0", - "reset {_shape}'s particle", - "", - "create a custom soul fire flame particle:", - "\tvelocity: inwards", - "\textra: 0.5", - "\tforce: true", - "set {_shape}'s particle to the last created particle" + "You can set it to any particle Skript can describe, including its data, offset, and extra value.", + "Each shape keeps its own copy, so changing the particle of one shape never changes another." }) +@Example("set {_shape}'s particle to flame particle") +@Example("set {_shape}'s particle to red dust particle") +@Example("set particle of {_shape} to 5 electric spark particles") +@Example("reset {_shape}'s particle") @Since("1.0.0") -public class ExprShapeParticle extends SimplePropertyExpression { +public class ExprShapeParticle extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeParticle.class, Particle.class, "[custom] particle", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeParticle.class, ParticleEffect.class, "[shape] particle", "shapes", false) + .supplier(ExprShapeParticle::new) + .build()); } @Override @Nullable - public Particle convert(Shape shape) { + public ParticleEffect convert(Shape shape) { return DrawData.of(shape).getParticle(); } @@ -48,7 +46,7 @@ public Particle convert(Shape shape) { @Nullable public Class[] acceptChange(ChangeMode mode) { return switch (mode) { - case SET, RESET, DELETE -> new Class[]{Particle.class}; + case SET, RESET, DELETE -> new Class[]{ParticleEffect.class}; case ADD, REMOVE, REMOVE_ALL -> new Class[0]; }; } @@ -58,8 +56,9 @@ public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { switch (mode) { case SET: if (delta == null || delta.length == 0) return; + // each shape gets its own copy, so changing one shape's particle later does not change the others for (Shape shape : getExpr().getArray(event)) - DrawData.of(shape).setParticle((Particle) delta[0]); + DrawData.of(shape).setParticle(((ParticleEffect) delta[0]).copy()); break; case RESET: case DELETE: @@ -75,13 +74,13 @@ public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { } @Override - public Class getReturnType() { - return Particle.class; + public Class getReturnType() { + return ParticleEffect.class; } @Override protected String getPropertyName() { - return "custom particle"; + return "shape particle"; } } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticleDensity.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticleDensity.java new file mode 100644 index 0000000..910d3fe --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeParticleDensity.java @@ -0,0 +1,144 @@ +package com.sovdee.skriptparticles.skript.shapes.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import org.skriptlang.skript.registration.SyntaxRegistry; +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.SkriptParser; +import ch.njol.util.Kleenean; +import com.sovdee.shapes.sampling.PointLimitExceededException; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.shapes.Shape; +import com.sovdee.skriptparticles.util.MathUtil; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; + +@Name("Shape Particle Density / Point Count") +@Description({ + "The density at which particles are spawned in a shape. This is in \"particles per meter\" (ppm), and defaults to 4. " + + "Be careful with this property, as it can cause lag if set to a high number. It's recommended to not go above 20 or so for this reason. 1000 ppm is the maximum value.", + "Keep in mind that this value scales with dimensions. A 1 meter line with a density of 20 will spawn 20 particles, but a 1 meter cube with a density of 20 will spawn 8,000 particles (2,400 if hollow).", + "Shapes are never drawn with more points than their point limit (see Shape Point Limit). If a density would produce more, the shape is drawn at the finest density that stays within the limit, and this expression returns that density instead.", + "Also, be aware that this may not be exact. Some shapes, like cuboids, will tweak the density a bit to ensure particles land exactly on the borders of the shape.", + "", + "This syntax also supports setting the point count directly, which is the amount of particles spawned in a shape.", + "Note that this is NOT exact. The actual amount of particles spawned will be the closest amount to the point count that the shape can be drawn with.", + "For example, if the point count of a solid cube is set to 100, the actual amount of particles spawned will be 125, which is the nearest cubic number.", + "The returned value of this expression will always be the accurate amount of particles spawned in a shape.", + "", + "Changing this will change the particle density or point count of the shape. Resetting or deleting it will set it back to the default density (4).", +}) +@Example("set {_shape}'s particle density to 10") +@Example("set {_shape}'s point count to 100") +@Example("reset {_shape}'s particle density") +@Example("reset {_shape}'s point count") +@Since("1.0.0") +public class ExprShapeParticleDensity extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + // "point count" rather than "particle count": Skript has its own "particle count of %particles%" expression, + // which would be chosen over this one whenever the type of the shape is not known at parse time. + // most shapes kinda disregard the point count, but it's still useful for some + // every shape responds to the particle density property pretty well though + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeParticleDensity.class, Number.class, "(density:(particle|point) density|point count)", "shapes", false) + .supplier(ExprShapeParticleDensity::new) + .build()); + } + + private boolean isDensity; + + @Override + public boolean init(Expression[] exprs, int matchedPattern, Kleenean isDelayed, SkriptParser.ParseResult parseResult) { + isDensity = parseResult.hasTag("density"); + return super.init(exprs, matchedPattern, isDelayed, parseResult); + } + + @Override + public @Nullable Number convert(Shape shape) { + PointSampler sampler = shape.getPointSampler(); + try { + if (isDensity) { + // the density actually in use, which may be coarser than the one set if the point cap applies + return 1 / sampler.getEffectiveDensity(shape); + } + return sampler.getPointCount(shape); + } catch (PointLimitExceededException e) { + return null; + } + } + + @Override + @Nullable + public Class[] acceptChange(ChangeMode mode) { + return switch (mode) { + case ADD, REMOVE, SET, RESET, DELETE -> new Class[]{Number.class}; + case REMOVE_ALL -> new Class[0]; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + Shape[] shapes = getExpr().getArray(event); + if (shapes.length == 0) + return; + if (mode == ChangeMode.DELETE || mode == ChangeMode.RESET) { + // back to the default density, limited by the shape's point cap + for (Shape shape : shapes) + shape.getPointSampler().resetDensity(); + return; + } + if (delta == null || delta.length == 0) + return; + double change = ((Number) delta[0]).doubleValue(); + switch (mode) { + case REMOVE: + change = -change; + case ADD: + for (Shape shape : shapes) { + PointSampler sampler = shape.getPointSampler(); + try { + if (isDensity) { + // clamp to 0.001 and 1000, enough to kill the client but not enough to cause an actual error + double particlesPerMeter = 1 / sampler.getEffectiveDensity(shape) + change; + sampler.setDensity(particlesPerMeter <= 0 ? 1000 : MathUtil.clamp(1 / particlesPerMeter, 0.001, 1000)); + } else { + // clamp to 1, the minimum amount of particles + sampler.setParticleCount(shape, Math.max(1, sampler.getPointCount(shape) + (int) change)); + } + } catch (PointLimitExceededException ignored) { + // the shape cannot be sampled as it is, so there is nothing to add to + } + } + break; + case SET: + for (Shape shape : shapes) { + PointSampler sampler = shape.getPointSampler(); + if (isDensity) { + sampler.setDensity(change <= 0 ? 1000 : MathUtil.clamp(1 / change, 0.001, 1000)); + } else { + sampler.setParticleCount(shape, Math.max(1, (int) change)); + } + } + break; + default: + assert false; + } + } + + @Override + public Class getReturnType() { + return Number.class; + } + + @Override + protected String getPropertyName() { + return isDensity ? "particle density" : "point count"; + } + +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePointLimit.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePointLimit.java new file mode 100644 index 0000000..add8a9a --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePointLimit.java @@ -0,0 +1,82 @@ +package com.sovdee.skriptparticles.skript.shapes.properties; + +import ch.njol.skript.classes.Changer.ChangeMode; +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.shapes.Shape; +import org.bukkit.event.Event; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Shape Point Limit") +@Description({ + "The most points a shape will be drawn with. If its particle density would produce more, the shape is drawn " + + "at the finest density that stays within the limit instead.", + "By default, the limit is 10,000 points, or 100,000 if the shape's particle density was set. " + + "100,000 is also the highest the limit can be set to.", + "Resetting or deleting it sets it back to the default." +}) +@Example("set the shape point limit of {_sphere} to 500") +@Example("add 1000 to {_shape}'s shape point limit") +@Example("reset the shape point limit of {_shape}") +@Since("2.0.0") +public class ExprShapePointLimit extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapePointLimit.class, Integer.class, "shape point limit", "shapes", false) + .supplier(ExprShapePointLimit::new) + .build()); + } + + @Override + public Integer convert(Shape shape) { + return shape.getPointSampler().getMaxPoints(); + } + + @Override + public Class @Nullable [] acceptChange(ChangeMode mode) { + return switch (mode) { + case SET, ADD, REMOVE, RESET, DELETE -> new Class[]{Number.class}; + case REMOVE_ALL -> null; + }; + } + + @Override + public void change(Event event, Object @Nullable [] delta, ChangeMode mode) { + Shape[] shapes = getExpr().getArray(event); + if (mode == ChangeMode.RESET || mode == ChangeMode.DELETE) { + for (Shape shape : shapes) + shape.getPointSampler().resetMaxPoints(); + return; + } + if (delta == null || delta.length == 0) + return; + int change = ((Number) delta[0]).intValue(); + for (Shape shape : shapes) { + PointSampler sampler = shape.getPointSampler(); + // the sampler clamps the limit to its allowed range + switch (mode) { + case SET -> sampler.setMaxPoints(change); + case ADD -> sampler.setMaxPoints(sampler.getMaxPoints() + change); + case REMOVE -> sampler.setMaxPoints(sampler.getMaxPoints() - change); + default -> {} + } + } + } + + @Override + public Class getReturnType() { + return Integer.class; + } + + @Override + protected String getPropertyName() { + return "shape point limit"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapePoints.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePoints.java similarity index 70% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapePoints.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePoints.java index dff9e4a..f149543 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapePoints.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapePoints.java @@ -1,11 +1,12 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.lang.Expression; +import org.skriptlang.skript.registration.SyntaxRegistry; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.Shape; @@ -22,17 +23,19 @@ "Returns all the vectors from the center of the shape to the various points particles would be drawn at.", "If you want the locations of the particles, it's advised use the 'shape locations' expression rather than doing it yourself." }) -@Examples({ - "set {_vectors::*} to points of (circle of radius 10)", - "teleport player to (player ~ random element of {_vectors::*})", - "", - "set {_randomVectorInEllipsoid} to random element of points of (solid ellipsoid of radius 10, 5, 2)" -}) +@Example(""" + set {_vectors::*} to points of (circle of radius 10) + teleport player to (player ~ random element of {_vectors::*}) + """) +@Example("set {_randomVectorInEllipsoid} to random element of points of (solid ellipsoid with radii 10, 5 and 2)") @Since("1.0.0") public class ExprShapePoints extends PropertyExpression { - static { - register(ExprShapePoints.class, Vector.class, "points", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapePoints.class, Vector.class, "points", "shapes", false) + .supplier(ExprShapePoints::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRadius.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRadius.java similarity index 80% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRadius.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRadius.java index 53e633e..af9e04c 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRadius.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRadius.java @@ -1,13 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.RadialShape; +import org.skriptlang.skript.registration.SyntaxRegistry; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -17,16 +18,17 @@ "For shapes like regular polygons, this is the distance from the center to a corner.", "Changing this will change the size of the shape. Resetting or deleting it will set it back to the default radius of 1." }) -@Examples({ - "set {_shape}'s radius to 5", - "set radius of {_shape} to 10", - "reset {_shape}'s radius" -}) +@Example("set {_shape}'s radius to 5") +@Example("set radius of {_shape} to 10") +@Example("reset {_shape}'s radius") @Since("1.0.0") public class ExprShapeRadius extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeRadius.class, Number.class, "radius", "radialshapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeRadius.class, Number.class, "radius", "radialshapes", false) + .supplier(ExprShapeRadius::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeReach.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeReach.java new file mode 100644 index 0000000..989fc5a --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeReach.java @@ -0,0 +1,54 @@ +package com.sovdee.skriptparticles.skript.shapes.properties; + +import ch.njol.skript.doc.Description; +import ch.njol.skript.doc.Example; +import ch.njol.skript.doc.Name; +import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; +import ch.njol.skript.expressions.base.SimplePropertyExpression; +import com.sovdee.shapes.sampling.PointLimitExceededException; +import com.sovdee.shapes.sampling.PointSampler; +import com.sovdee.shapes.shapes.Shape; +import org.jetbrains.annotations.Nullable; +import org.skriptlang.skript.registration.SyntaxRegistry; + +@Name("Shape Reach") +@Description({ + "How far the furthest point of a shape is from the location it is drawn at, after its rotation, scale and offset.", + "Useful for finding the players close enough to see a shape, or how much room it needs." +}) +@Example(""" + set {_reach} to shape reach of {_shape} + loop all players in radius {_reach} + 32 of {_location}: + send "Look up!" to loop-player + """) +@Since("2.0.0") +public class ExprShapeReach extends SimplePropertyExpression { + + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeReach.class, Number.class, "shape reach", "shapes", false) + .supplier(ExprShapeReach::new) + .build()); + } + + @Override + public @Nullable Number convert(Shape shape) { + PointSampler sampler = shape.getPointSampler(); + try { + return sampler.boundsOf(sampler.getPoints(shape)).maxDistance(); + } catch (PointLimitExceededException e) { + return null; + } + } + + @Override + public Class getReturnType() { + return Number.class; + } + + @Override + protected String getPropertyName() { + return "shape reach"; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRelativeAxis.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRelativeAxis.java similarity index 66% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRelativeAxis.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRelativeAxis.java index cf3c0ff..e18d937 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeRelativeAxis.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeRelativeAxis.java @@ -1,11 +1,12 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; +import org.skriptlang.skript.registration.SyntaxRegistry; import ch.njol.skript.lang.Expression; import ch.njol.skript.lang.SkriptParser; import ch.njol.util.Kleenean; @@ -19,19 +20,24 @@ "Returns the relative axis of a shape. These are the x, y, z axis of the shape AFTER rotation.", "These are meant to be used to work within the rotated frame of reference of the shape." }) -@Examples({ - "set {_vX} to {_shape}'s relative x axis", - "set {_vY} to {_shape}'s relative y axis", - "set {_vZ} to {_shape}'s relative z axis", - "rotate shape {_shape} around {_vy} by 90 degrees", - "set {_vY-offset} to {_shape}'s relative y axis ++ {_shape}'s relative z axis", - "rotate shape {_shape} around {_vY-offset} by 60 degrees" -}) +@Example("set {_vX} to {_shape}'s relative x axis") +@Example(""" + set {_vY} to {_shape}'s relative y axis + set {_vZ} to {_shape}'s relative z axis + rotate shape {_shape} around {_vY} by 90 degrees + """) +@Example(""" + set {_vY-offset} to {_shape}'s relative y axis + {_shape}'s relative z axis + rotate shape {_shape} around {_vY-offset} by 60 degrees + """) @Since("1.0.0") public class ExprShapeRelativeAxis extends SimplePropertyExpression { - static { - PropertyExpression.register(ExprShapeRelativeAxis.class, Vector.class, "(relative|local) (:x|:y|:z)(-| )axis", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeRelativeAxis.class, Vector.class, "(relative|local) (:x|:y|:z)(-| )axis", "shapes", false) + .supplier(ExprShapeRelativeAxis::new) + .build()); } private int axis; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSideLength.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSideLength.java similarity index 78% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSideLength.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSideLength.java index 7f7c56e..661d42a 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSideLength.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSideLength.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.PolyShape; +import org.skriptlang.skript.registration.SyntaxRegistry; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -17,16 +19,17 @@ "Resetting this will set the side length to the default value of 1 and non-positive values will be set to 0.001.", "Note that changing this property will not affect custom polygons, only regular polygons and polyhedrons." }) -@Examples({ - "set side length of {_shape} to 5", - "set {_shape}'s side length to 6", - "send side length of {_shape}" -}) +@Example("set side length of {_shape} to 5") +@Example("set {_shape}'s side length to 6") +@Example("send side length of {_shape}") @Since("1.0.0") public class ExprShapeSideLength extends SimplePropertyExpression { - static { - register(ExprShapeSideLength.class, Number.class, "side length", "polyshapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeSideLength.class, Number.class, "side length", "polyshapes", false) + .supplier(ExprShapeSideLength::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSides.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSides.java similarity index 78% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSides.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSides.java index f93736a..ac43c8d 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprShapeSides.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprShapeSides.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.PolyShape; +import org.skriptlang.skript.registration.SyntaxRegistry; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -17,16 +19,17 @@ "Note that custom polygons will return their side count, but will not be affected by this expression. ", "Polyhedrons will return their face count, and can only be set to 4, 8, 12, or 20." }) -@Examples({ - "set sides of {_shape} to 5", - "set {_shape}'s side count to 6", - "send sides of {_shape}" -}) +@Example("set sides of {_shape} to 5") +@Example("set {_shape}'s side count to 6") +@Example("send sides of {_shape}") @Since("1.0.0") public class ExprShapeSides extends SimplePropertyExpression { - static { - register(ExprShapeSides.class, Integer.class, "side(s| count)", "polyshapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprShapeSides.class, Integer.class, "side(s| count)", "polyshapes", false) + .supplier(ExprShapeSides::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarPoints.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarPoints.java similarity index 76% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarPoints.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarPoints.java index 94f30eb..c10170c 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarPoints.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarPoints.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import com.sovdee.shapes.shapes.Star; +import org.skriptlang.skript.registration.SyntaxRegistry; import com.sovdee.shapes.shapes.Shape; import org.bukkit.event.Event; import org.jetbrains.annotations.Nullable; @@ -16,16 +18,17 @@ "Returns the number of points on a star. This is the number of points on the star, not the number of particles drawn.", "Changing this will change the number of points on the star. The number of points must be at least 2." }) -@Examples({ - "set {_shape}'s star points to 5", - "set star points of {_shape} to 10", - "set star points of {_shape} to 2 * (star points of {_shape})" -}) +@Example("set {_shape}'s star points to 5") +@Example("set star points of {_shape} to 10") +@Example("set star points of {_shape} to 2 * (star points of {_shape})") @Since("1.0.1") public class ExprStarPoints extends SimplePropertyExpression { - static { - register(ExprStarPoints.class, Number.class, "star points", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprStarPoints.class, Number.class, "star points", "shapes", false) + .supplier(ExprStarPoints::new) + .build()); } @Override diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarRadii.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarRadii.java similarity index 82% rename from skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarRadii.java rename to skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarRadii.java index 5108987..1606eb9 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/elements/expressions/properties/ExprStarRadii.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/skript/shapes/properties/ExprStarRadii.java @@ -1,12 +1,14 @@ -package com.sovdee.skriptparticles.elements.expressions.properties; +package com.sovdee.skriptparticles.skript.shapes.properties; import ch.njol.skript.classes.Changer; import ch.njol.skript.doc.Description; -import ch.njol.skript.doc.Examples; +import ch.njol.skript.doc.Example; import ch.njol.skript.doc.Name; import ch.njol.skript.doc.Since; +import ch.njol.skript.expressions.base.PropertyExpression; import ch.njol.skript.expressions.base.SimplePropertyExpression; import ch.njol.skript.lang.Expression; +import org.skriptlang.skript.registration.SyntaxRegistry; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.util.Kleenean; import com.sovdee.shapes.shapes.Shape; @@ -20,16 +22,17 @@ "the innermost points, and likewise the outer radius is the distance to the tips of the star.", "Changing this will change the size of the star. Both radii must be greater than 0." }) -@Examples({ - "set {_star} to a star with 5 points, inner radius 1, and outer radius 2", - "set {_star}'s inner radius to 2", - "set {_star}'s outer radius to 3" -}) +@Example("set {_star} to a star with 5 points, inner radius 1, and outer radius 2") +@Example("set {_star}'s inner radius to 2") +@Example("set {_star}'s outer radius to 3") @Since("1.0.1") public class ExprStarRadii extends SimplePropertyExpression { - static { - register(ExprStarRadii.class, Number.class, "(:inner|outer) radius", "shapes"); + public static void register(SyntaxRegistry registry) { + registry.register(SyntaxRegistry.EXPRESSION, + PropertyExpression.infoBuilder(ExprStarRadii.class, Number.class, "(:inner|outer) radius", "shapes", false) + .supplier(ExprStarRadii::new) + .build()); } private boolean isInner; diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/DynamicLocation.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/DynamicLocation.java index 1eb82a1..7f87f1e 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/DynamicLocation.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/DynamicLocation.java @@ -3,7 +3,8 @@ import ch.njol.skript.util.Direction; import org.bukkit.Location; import org.bukkit.entity.Entity; -import org.checkerframework.checker.nullness.qual.Nullable; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Contract; /** @@ -22,7 +23,7 @@ public class DynamicLocation { * Creates a dynamic location with the given entity * @param entity the entity to create the dynamic location from */ - public DynamicLocation(Entity entity) { + public DynamicLocation(@NotNull Entity entity) { this.entity = entity; } @@ -49,7 +50,7 @@ public DynamicLocation(Location location, @Nullable Direction direction) { * @param entity the entity to create the dynamic location from * @param direction the direction to create the dynamic location from */ - public DynamicLocation(Entity entity, @Nullable Direction direction) { + public DynamicLocation(@NotNull Entity entity, @Nullable Direction direction) { this.entity = entity; this.direction = direction; } @@ -157,7 +158,7 @@ public Direction getDirection() { * * @param direction the direction to set the dynamic location to */ - public void setDirection(Direction direction) { + public void setDirection(@Nullable Direction direction) { this.direction = direction; } @@ -179,6 +180,15 @@ public DynamicLocation clone() { return new DynamicLocation(this); } + /** + * Compares this {@code DynamicLocation} to another object for equality. Two instances are + * equal when they reference the same entity (by {@link Entity#equals}) or the same location + * (by {@link Location#equals}), and both have equal directions (or both have no direction). + * + * @param obj the object to compare against + * @return {@code true} if {@code obj} is a {@code DynamicLocation} with the same entity or + * location and the same direction + */ @Override public boolean equals(Object obj) { if (!(obj instanceof DynamicLocation dynamicLocation)) diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/MathUtil.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/MathUtil.java index c92e297..59aad07 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/MathUtil.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/MathUtil.java @@ -5,9 +5,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; public class MathUtil { public static final double EPSILON = 0.0001; @@ -16,8 +14,8 @@ public static double clamp(double value, double min, double max) { return Math.clamp(value, min, max); } - public static Set calculateLine(Vector start, Vector end, double particleDensity) { - Set points = new LinkedHashSet<>(); + public static List calculateLine(Vector start, Vector end, double particleDensity) { + List points = new ArrayList<>(); Vector direction = end.clone().subtract(start); double length = direction.length(); double step = length / Math.round(length / particleDensity); @@ -29,12 +27,12 @@ public static Set calculateLine(Vector start, Vector end, double particl return points; } - public static List> batch(Collection toDraw, double millisecondsPerPoint) { - List> batches = new ArrayList<>(); + public static List> batch(Collection toDraw, double millisecondsPerPoint) { + List> batches = new ArrayList<>(); double totalDuration = 0; - Iterator pointsIterator = toDraw.iterator(); + Iterator pointsIterator = toDraw.iterator(); while (pointsIterator.hasNext()) { - List batch = new ArrayList<>(); + List batch = new ArrayList<>(); while (totalDuration < 50 && pointsIterator.hasNext()) { totalDuration += millisecondsPerPoint; batch.add(pointsIterator.next()); diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ParticleUtil.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ParticleUtil.java index 6e791ef..829f6fc 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ParticleUtil.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ParticleUtil.java @@ -7,26 +7,27 @@ import org.bukkit.Particle.DustOptions; import org.bukkit.entity.Player; import org.bukkit.util.Vector; +import org.skriptlang.skript.bukkit.particles.particleeffects.ParticleEffect; import java.util.Collection; -import java.util.Set; +import java.util.List; -/* - * Thanks to ShaneBee at SkBee for the original code. - */ public class ParticleUtil { private static final ParticleBuilder Y_AXIS = new ParticleBuilder(Particle.DUST).data(new DustOptions(DyeColor.LIME.getColor(), 0.5f)); private static final ParticleBuilder X_AXIS = new ParticleBuilder(Particle.DUST).data(new DustOptions(DyeColor.RED.getColor(), 0.5f)); private static final ParticleBuilder Z_AXIS = new ParticleBuilder(Particle.DUST).data(new DustOptions(DyeColor.BLUE.getColor(), 0.5f)); - public static com.sovdee.skriptparticles.particles.Particle getDefaultParticle() { - return (com.sovdee.skriptparticles.particles.Particle) new com.sovdee.skriptparticles.particles.Particle(Particle.FLAME).count(1).extra(0); + public static ParticleEffect getDefaultParticle() { + ParticleEffect particle = ParticleEffect.of(Particle.FLAME).count(1); + // not chained: ParticleEffect only returns itself from speed() on Skript 2.17+ + particle.speed(0); + return particle; } public static void drawAxes(Location location, Quaternion orientation, Collection recipients) { - Set yAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(0, 1, 0), 0.2); - Set xAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(1, 0, 0), 0.2); - Set zAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(0, 0, 1), 0.2); + List yAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(0, 1, 0), 0.2); + List xAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(1, 0, 0), 0.2); + List zAxis = MathUtil.calculateLine(new Vector(0, 0, 0), new Vector(0, 0, 1), 0.2); yAxis = orientation.transform(yAxis); xAxis = orientation.transform(xAxis); diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Point.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Point.java index 796df84..ae98bef 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Point.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Point.java @@ -3,18 +3,17 @@ import org.bukkit.Location; import org.bukkit.entity.Entity; import org.bukkit.util.Vector; -import org.checkerframework.checker.nullness.qual.Nullable; import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; public class Point { private final T rawValue; private final Class type; - private boolean isDynamic; + private final boolean isDynamic; public Point(T value, Class type) { this(value, type, false); @@ -37,20 +36,17 @@ public static List> of(List values) { @Nullable @Contract("!null -> !null") public static Point of(@Nullable Object value) { - if (value == null) - return null; - - if (value instanceof Vector v) { - return Point.of(v); - } else if (value instanceof Entity v) { - return Point.of(v); - } else if (value instanceof Location v) { - return Point.of(v); - } else if (value instanceof DynamicLocation v) { - return Point.of(v); - } - assert false; - return null; + return switch (value) { + case null -> null; + case Vector v -> Point.of(v); + case Entity v -> Point.of(v); + case Location v -> Point.of(v); + case DynamicLocation v -> Point.of(v); + default -> { + assert false; + yield null; + } + }; } public static Point of(Vector value) { @@ -147,4 +143,4 @@ public boolean isDynamic() { return isDynamic; } -} \ No newline at end of file +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Quaternion.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Quaternion.java index 0310eac..eff2475 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Quaternion.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/Quaternion.java @@ -4,9 +4,7 @@ import org.joml.Quaternionf; import org.joml.Vector3f; -import java.util.HashSet; import java.util.List; -import java.util.Set; /** * Helper class for JOML's Quaternionf class @@ -46,18 +44,10 @@ public Quaternion(Quaternion quaternion) { } public List transform(List vectors) { - vectors.replaceAll(this::transform); + vectors.forEach(this::transform); return vectors; } - public Set transform(Set vectors) { - Set newVectors = new HashSet<>(); - for (Vector vector : vectors) { - newVectors.add(this.transform(vector)); - } - return newVectors; - } - public Vector transform(Vector vector) { Vector3f vector3f = new Vector3f((float) vector.getX(), (float) vector.getY(), (float) vector.getZ()); vector3f = this.transform(vector3f); diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ReflectionUtils.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ReflectionUtils.java deleted file mode 100644 index 68d78ff..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/ReflectionUtils.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.sovdee.skriptparticles.util; - -import org.bukkit.Bukkit; -import org.checkerframework.checker.nullness.qual.Nullable; - -/* - * Thanks to ShaneBee at SkBee for the original code. - * This is meant to fill the gap when SkBee isn't installed. - */ - -public class ReflectionUtils { - private static final String CRAFTBUKKIT_PACKAGE = Bukkit.getServer().getClass().getPackage().getName(); - private static final boolean DEBUG = false; //SkBee.getPlugin().getPluginConfig().SETTINGS_DEBUG; - - @Nullable - public static Class getOBCClass(String obcClassString) { - String name = CRAFTBUKKIT_PACKAGE + "." + obcClassString; - try { - return Class.forName(name); - } catch (ClassNotFoundException e) { - if (DEBUG) { - e.printStackTrace(); - } - return null; - } - } -} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/RelativePoints.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/RelativePoints.java new file mode 100644 index 0000000..72ba759 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/RelativePoints.java @@ -0,0 +1,43 @@ +package com.sovdee.skriptparticles.util; + +import org.bukkit.Location; +import org.joml.Vector3d; + +import java.util.function.Supplier; + +/** + * Helpers for shapes that are defined by locations or entities in the world. + *

+ * Shapes are always drawn relative to a location, so a shape defined by world positions is built from the + * positions of its points relative to an anchor, and then drawn at that anchor. + */ +public final class RelativePoints { + + private RelativePoints() {} + + /** + * @return the current position of {@code point}, relative to the current position of {@code anchor} + */ + public static Vector3d offset(DynamicLocation anchor, DynamicLocation point) { + Location anchorLocation = anchor.getLocation(); + Location pointLocation = point.getLocation(); + return new Vector3d( + pointLocation.getX() - anchorLocation.getX(), + pointLocation.getY() - anchorLocation.getY(), + pointLocation.getZ() - anchorLocation.getZ()); + } + + /** + * @return a supplier of the position of {@code point} relative to {@code anchor}, which follows both as they move + */ + public static Supplier supplier(DynamicLocation anchor, DynamicLocation point) { + return () -> offset(anchor, point); + } + + /** + * @return a supplier of the anchor's own relative position, which is always the origin + */ + public static Supplier origin() { + return Vector3d::new; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/SkriptUtil.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/SkriptUtil.java new file mode 100644 index 0000000..af7f2c5 --- /dev/null +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/SkriptUtil.java @@ -0,0 +1,23 @@ +package com.sovdee.skriptparticles.util; + +import ch.njol.skript.lang.Expression; +import ch.njol.skript.lang.ExpressionSection; +import ch.njol.skript.lang.parser.ParserInstance; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class SkriptUtil { + + @SuppressWarnings({"UnstableApiUsage", "unchecked"}) + public static @Nullable T getCurrentSectionExpression(Class exprSectionClass, ParserInstance parser) { + T expression = null; + List list = parser.getCurrentSections(ExpressionSection.class); + for (ExpressionSection candidateSection : list) { + Expression candidate = candidateSection.getAsExpression(); + if (exprSectionClass.isInstance(candidate)) + expression = (T) candidate; // overwriting is good since last should be only one. + } + return expression; + } +} diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/VectorConversion.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/VectorConversion.java index 4780668..3c4d293 100644 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/VectorConversion.java +++ b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/VectorConversion.java @@ -3,8 +3,8 @@ import org.bukkit.util.Vector; import org.joml.Vector3d; -import java.util.LinkedHashSet; -import java.util.Set; +import java.util.ArrayList; +import java.util.List; /** * Utility class for converting between Bukkit Vectors and JOML Vector3d. @@ -12,23 +12,23 @@ public class VectorConversion { public static Vector toBukkit(Vector3d v) { - return new Vector(v.x, v.y, v.z); + return Vector.fromJOML(v); } public static Vector3d toJOML(Vector v) { - return new Vector3d(v.getX(), v.getY(), v.getZ()); + return v.toVector3d(); } - public static Set toBukkit(Set points) { - Set result = new LinkedHashSet<>(); + public static List toBukkit(List points) { + List result = new ArrayList<>(); for (Vector3d v : points) { result.add(toBukkit(v)); } return result; } - public static Set toJOML(Set points) { - Set result = new LinkedHashSet<>(); + public static List toJOML(List points) { + List result = new ArrayList<>(); for (Vector v : points) { result.add(toJOML(v)); } diff --git a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/package-info.java b/skript-particle/src/main/java/com/sovdee/skriptparticles/util/package-info.java deleted file mode 100644 index 92f5e39..0000000 --- a/skript-particle/src/main/java/com/sovdee/skriptparticles/util/package-info.java +++ /dev/null @@ -1,5 +0,0 @@ -@DefaultQualifier(NonNull.class) -package com.sovdee.skriptparticles.util; - -import org.checkerframework.checker.nullness.qual.NonNull; -import org.checkerframework.framework.qual.DefaultQualifier; diff --git a/skript-particle/src/main/resources/lang/english.lang b/skript-particle/src/main/resources/lang/english.lang index 5f8d858..79ffbde 100644 --- a/skript-particle/src/main/resources/lang/english.lang +++ b/skript-particle/src/main/resources/lang/english.lang @@ -1,18 +1,11 @@ version: @version@ types: - customparticle: custom particle¦s - particlemotion: particle motion¦s shape: shape¦s radialshape: radial shape¦s lwhshape: length/width/height shape¦s cutoffshape: cutoff shape¦s polyshape: polygonal shape¦s shapestyle: shape style¦s - -particle motions: - inwards: inwards, inwards motion, towards center, towards the center - outwards: outwards, outwards motion, away from center, away from the center - clockwise: clockwise, clockwise motion - counterclockwise: anticlockwise, anticlockwise motion, counterclockwise, counterclockwise motion - none: none, no motion + pointmodifier: point modifier¦s + easing: easing¦s diff --git a/skript-particle/src/test/gen_doc_examples.py b/skript-particle/src/test/gen_doc_examples.py new file mode 100644 index 0000000..0bbf072 --- /dev/null +++ b/skript-particle/src/test/gen_doc_examples.py @@ -0,0 +1,111 @@ +"""Generates a Skript test script that parses every @Example/@Examples string of the addon's syntax classes.""" +import re +import sys +from pathlib import Path + +src = Path(sys.argv[1]) +out = Path(sys.argv[2]) + +STRING = re.compile(r'"((?:[^"\\]|\\.)*)"') + + +def unescape(s: str) -> str: + return s.encode("utf-8").decode("unicode_escape").encode("latin-1").decode("utf-8") + + +def examples_of(text: str) -> list[str]: + lines = [] + for m in re.finditer(r'@Examples?\(', text): + i = m.end() + depth = 1 + j = i + in_str = False + while depth: + c = text[j] + if not in_str and text.startswith('"""', j): + j = text.index('"""', j + 3) + 3 + continue + if in_str: + if c == '\\': + j += 1 + elif c == '"': + in_str = False + elif c == '"': + in_str = True + elif c == '(': + depth += 1 + elif c == ')': + depth -= 1 + j += 1 + inner = text[i:j - 1].strip() + if inner.startswith('"""'): + # text block: strip the incidental indentation, which the closing delimiter's line also counts towards + body = inner[3:-3] + raw = body.split("\n")[1:] + margin = min(len(l) - len(l.lstrip()) for l in raw if l.strip() or l is raw[-1]) + lines.extend(unescape(l[margin:]) for l in raw if l.strip()) + else: + for s in STRING.findall(inner): + lines.extend(unescape(s).split("\n")) + return lines + + +def indent_of(line: str) -> int: + expanded = line.replace(" ", "\t") + return len(expanded) - len(expanded.lstrip("\t")) + + +def normalise(line: str) -> str: + return "\t" * indent_of(line) + line.strip() + + +def close_empty_sections(lines: list[str]) -> list[str]: + result = [] + for k, line in enumerate(lines): + result.append(line) + if line.rstrip().endswith(":") and not line.lstrip().startswith("#"): + nxt = lines[k + 1] if k + 1 < len(lines) else None + if nxt is None or indent_of(nxt) <= indent_of(line): + result.append("\t" * (indent_of(line) + 1) + "stop") + return result + + +blocks = [] # (name, structure lines) +for path in sorted(src.rglob("*.java")): + lines = [normalise(l) for l in examples_of(path.read_text(encoding="utf-8")) if l.strip()] + if not lines: + continue + functions, body = [], [] + target = body + for line in lines: + if line.startswith("function "): + target = functions + elif indent_of(line) == 0: + target = body + target.append(line) + name = path.stem + if functions: + blocks.append((name + "-functions", close_empty_sections(functions))) + if body: + trigger = ["command /spdoc-" + name.lower() + ":", "\ttrigger:"] + trigger += ["\t\t" + l for l in close_empty_sections(body)] + blocks.append((name, trigger)) + +with out.open("w", encoding="utf-8", newline="\n") as f: + f.write("# GENERATED by src/test/gen_doc_examples.py - do not edit. Every documentation example must parse.\n") + f.write("# Regenerate after changing any @Example (from the repository root):\n") + f.write("# python skript-particle/src/test/gen_doc_examples.py" + " skript-particle/src/main/java/com/sovdee/skriptparticles/skript" + " skript-particle/src/test/scripts/docexamples.sk\n\n") + for name, structure in blocks: + f.write("parse:\n") + f.write("\tresults: {sp::doc::%s::*}\n" % name) + f.write("\tcode:\n") + for l in structure: + f.write("\t\t" + l + "\n") + f.write("\n") + for name, _ in blocks: + f.write('test "docs of %s parse":\n' % name) + f.write('\tassert {sp::doc::%s::*} is not set with "%%{sp::doc::%s::*}%%"\n' % (name, name)) + f.write('\tdelete {sp::doc::%s::*}\n\n' % name) +print(len(blocks), "blocks") diff --git a/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/ParticleRenderContextTest.java b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/ParticleRenderContextTest.java new file mode 100644 index 0000000..a5bf36e --- /dev/null +++ b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/ParticleRenderContextTest.java @@ -0,0 +1,69 @@ +package com.sovdee.skriptparticles.rendering; + +import org.bukkit.Color; +import org.bukkit.Particle.DustOptions; +import com.destroystokyo.paper.ParticleBuilder; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * One context is reused for every point of a draw, so {@link ParticleRenderContext#reset()} is what + * stops one point's modifier output from leaking into the next point. + */ +class ParticleRenderContextTest { + + @Test + void startsWithTheDrawnParticle() { + DustOptions dust = new DustOptions(Color.RED, 2f); + ParticleBuilder particle = new ParticleBuilder(org.bukkit.Particle.DUST); + particle.data(dust); + ParticleRenderContext ctx = new ParticleRenderContext(particle); + assertEquals(org.bukkit.Particle.DUST, ctx.particle); + assertSame(dust, ctx.data); + assertSame(dust, ctx.defaultData()); + assertTrue(ctx.isDefaultParticle()); + assertTrue(ctx.visible); + assertFalse(ctx.hasMotion); + } + + @Test + void isDefaultParticle_tracksChanges() { + ParticleRenderContext ctx = new ParticleRenderContext(new ParticleBuilder(org.bukkit.Particle.FLAME)); + ctx.particle = org.bukkit.Particle.DUST; + assertFalse(ctx.isDefaultParticle()); + ctx.reset(); + assertTrue(ctx.isDefaultParticle()); + ctx.data = new DustOptions(Color.BLUE, 1f); + assertFalse(ctx.isDefaultParticle(), "changed data alone also counts"); + } + + @Test + void reset_clearsEverythingAModifierCanWrite() { + ParticleRenderContext ctx = new ParticleRenderContext(new ParticleBuilder(org.bukkit.Particle.FLAME)); + ctx.particle = org.bukkit.Particle.DUST; + ctx.data = new DustOptions(Color.GREEN, 3f); + ctx.visible = false; + ctx.displacementX = 1; + ctx.displacementY = 2; + ctx.displacementZ = 3; + ctx.motionX = 4; + ctx.motionY = 5; + ctx.motionZ = 6; + ctx.hasMotion = true; + + ctx.reset(); + + assertEquals(org.bukkit.Particle.FLAME, ctx.particle); + assertNull(ctx.data); + assertTrue(ctx.isDefaultParticle()); + assertTrue(ctx.visible); + assertEquals(0, ctx.displacementX); + assertEquals(0, ctx.displacementY); + assertEquals(0, ctx.displacementZ); + assertEquals(0, ctx.motionX); + assertEquals(0, ctx.motionY); + assertEquals(0, ctx.motionZ); + assertFalse(ctx.hasMotion); + } +} diff --git a/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/ColorStopTest.java b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/ColorStopTest.java new file mode 100644 index 0000000..3285f65 --- /dev/null +++ b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/ColorStopTest.java @@ -0,0 +1,84 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import org.bukkit.Color; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ColorStopTest { + + private static final Color BLACK = Color.fromRGB(0, 0, 0); + private static final Color WHITE = Color.fromRGB(255, 255, 255); + private static final List BLACK_TO_WHITE = List.of(new ColorStop(0, BLACK), new ColorStop(1, WHITE)); + + @Test + void noStops_isWhite() { + assertEquals(Color.WHITE, ColorStop.interpolate(List.of(), 0.5)); + } + + @Test + void oneStop_isThatColorEverywhere() { + List stops = List.of(new ColorStop(0.3, Color.RED)); + for (double t : new double[]{-1, 0, 0.3, 0.9, 2}) + assertEquals(Color.RED, ColorStop.interpolate(stops, t)); + } + + @Test + void endpoints_matchTheirStops() { + assertEquals(BLACK, ColorStop.interpolate(BLACK_TO_WHITE, 0)); + assertEquals(WHITE, ColorStop.interpolate(BLACK_TO_WHITE, 1)); + } + + @Test + void blendsLinearlyBetweenStops() { + assertEquals(Color.fromRGB(127, 127, 127), ColorStop.interpolate(BLACK_TO_WHITE, 0.5)); + assertEquals(Color.fromRGB(63, 63, 63), ColorStop.interpolate(BLACK_TO_WHITE, 0.25)); + // channels are blended independently + Color mixed = ColorStop.interpolate(List.of( + new ColorStop(0, Color.fromRGB(255, 0, 100)), + new ColorStop(1, Color.fromRGB(0, 255, 100))), 0.5); + assertEquals(Color.fromRGB(127, 127, 100), mixed); + } + + @Test + void clampsOutOfRangeInput() { + assertEquals(BLACK, ColorStop.interpolate(BLACK_TO_WHITE, -3)); + assertEquals(WHITE, ColorStop.interpolate(BLACK_TO_WHITE, 7)); + assertEquals(BLACK, ColorStop.interpolate(BLACK_TO_WHITE, Double.NEGATIVE_INFINITY)); + } + + @Test + void beforeTheFirstAndAfterTheLastStop_holdTheirColor() { + List stops = List.of(new ColorStop(0.25, Color.RED), new ColorStop(0.75, Color.BLUE)); + assertEquals(Color.RED, ColorStop.interpolate(stops, 0)); + assertEquals(Color.RED, ColorStop.interpolate(stops, 0.25)); + assertEquals(Color.BLUE, ColorStop.interpolate(stops, 0.75)); + assertEquals(Color.BLUE, ColorStop.interpolate(stops, 1)); + assertEquals(Color.fromRGB(127, 0, 127), ColorStop.interpolate(stops, 0.5)); + } + + @Test + void picksTheRightSegment() { + List stops = List.of( + new ColorStop(0, Color.RED), + new ColorStop(0.5, Color.LIME), + new ColorStop(1, Color.BLUE)); + assertEquals(Color.fromRGB(127, 127, 0), ColorStop.interpolate(stops, 0.25)); + assertEquals(Color.LIME, ColorStop.interpolate(stops, 0.5)); + assertEquals(Color.fromRGB(0, 127, 127), ColorStop.interpolate(stops, 0.75)); + } + + @Test + void coincidentStops_makeAHardEdgeInsteadOfFailing() { + List stops = List.of( + new ColorStop(0, Color.RED), + new ColorStop(0.5, Color.RED), + new ColorStop(0.5, Color.BLUE), + new ColorStop(1, Color.BLUE)); + assertEquals(Color.RED, ColorStop.interpolate(stops, 0.49)); + assertEquals(Color.RED, ColorStop.interpolate(stops, 0.5)); + assertEquals(Color.BLUE, ColorStop.interpolate(stops, 0.51)); + } +} diff --git a/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifierTest.java b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifierTest.java new file mode 100644 index 0000000..f98fd81 --- /dev/null +++ b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/CompositeModifierTest.java @@ -0,0 +1,119 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.PointContext; +import com.sovdee.shapes.modifiers.PointModifier; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.modifiers.StandardInput; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.Color; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class CompositeModifierTest { + + /** Records the order it ran in and adds its id to x. */ + private static final class Step implements PointModifier { + final int id; + final List log; + + Step(int id, List log) { + this.id = id; + this.log = log; + } + + @Override + public void prepare(ShapeBounds bounds) { + log.add("prepare " + id); + } + + @Override + public void modify(PointContext point) { + log.add("modify " + id + " saw x=" + point.x); + point.x += id; + } + + @Override + public int modifierHash() { + return id; + } + + @Override + public Step clone() { + return new Step(id, log); + } + } + + @Test + void runsModifiersInOrder_eachSeeingThePreviousOutput() { + List log = new ArrayList<>(); + CompositeModifier composite = new CompositeModifier<>(List.of(new Step(1, log), new Step(10, log))); + PointContext ctx = new PointContext(); + composite.prepare(ShapeBounds.compute(List.of(new Vector3d()))); + composite.modify(ctx); + assertEquals(List.of("prepare 1", "prepare 10", "modify 1 saw x=0.0", "modify 10 saw x=1.0"), log); + assertEquals(11, ctx.x); + } + + @Test + void contextType_isTheMostSpecificOneNeeded() { + CompositeModifier composite = new CompositeModifier<>(); + assertEquals(PointContext.class, composite.contextType()); + composite.addModifier(new Step(1, new ArrayList<>())); + assertEquals(PointContext.class, composite.contextType()); + + @SuppressWarnings({"unchecked", "rawtypes"}) + PointModifier gradient = (PointModifier) new GradientModifier(Color.RED, Color.BLUE, StandardInput.X); + composite.addModifier(gradient); + assertEquals(ParticleRenderContext.class, composite.contextType(), + "one render modifier makes the whole chain need a render context"); + } + + @Test + void clone_copiesEveryModifier() { + List log = new ArrayList<>(); + Step a = new Step(1, log), b = new Step(2, log); + CompositeModifier composite = new CompositeModifier<>(List.of(a, b)); + CompositeModifier copy = composite.clone(); + assertEquals(2, copy.getModifiers().size()); + assertNotSame(a, copy.getModifiers().get(0)); + assertNotSame(b, copy.getModifiers().get(1)); + assertEquals(composite.modifierHash(), copy.modifierHash()); + + copy.addModifier(new Step(3, log)); + assertEquals(2, composite.getModifiers().size()); + } + + @Test + void constructorCopiesTheList() { + List> list = new ArrayList<>(); + list.add(new Step(1, new ArrayList<>())); + CompositeModifier composite = new CompositeModifier<>(list); + list.clear(); + assertFalse(composite.isEmpty()); + } + + @Test + void modifierHash_dependsOnOrder() { + List log = new ArrayList<>(); + int ab = new CompositeModifier<>(List.>of(new Step(1, log), new Step(2, log))).modifierHash(); + int ba = new CompositeModifier<>(List.>of(new Step(2, log), new Step(1, log))).modifierHash(); + assertNotEquals(ab, ba); + } + + @Test + void addAndRemove() { + CompositeModifier composite = new CompositeModifier<>(); + assertTrue(composite.isEmpty()); + Step step = new Step(1, new ArrayList<>()); + composite.addModifier(step); + assertFalse(composite.isEmpty()); + assertTrue(composite.removeModifier(step)); + assertFalse(composite.removeModifier(step)); + assertTrue(composite.isEmpty()); + } +} diff --git a/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifierTest.java b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifierTest.java new file mode 100644 index 0000000..c8f9d04 --- /dev/null +++ b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/GradientModifierTest.java @@ -0,0 +1,187 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.sovdee.shapes.modifiers.EasingFunction; +import com.sovdee.shapes.modifiers.ShapeBounds; +import com.sovdee.shapes.modifiers.StandardInput; +import com.destroystokyo.paper.ParticleBuilder; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import org.bukkit.Color; +import org.bukkit.Particle.DustOptions; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Gradients are the most used render modifier and run once per point, every frame. + */ +class GradientModifierTest { + + private static final Color BLACK = Color.fromRGB(0, 0, 0); + private static final Color WHITE = Color.fromRGB(255, 255, 255); + /** x spans [0, 10], so StandardInput.X is x / 10. */ + private static final ShapeBounds BOUNDS = ShapeBounds.compute(List.of(new Vector3d(0, 0, 0), new Vector3d(10, 0, 0))); + + private static ParticleRenderContext context(ParticleBuilder particle) { + ParticleRenderContext ctx = new ParticleRenderContext(particle); + ctx.bounds = BOUNDS; + return ctx; + } + + private static ParticleRenderContext flame() { + return context(new ParticleBuilder(org.bukkit.Particle.FLAME)); + } + + private static ParticleRenderContext dust(float size) { + ParticleBuilder particle = new ParticleBuilder(org.bukkit.Particle.DUST); + particle.data(new DustOptions(Color.RED, size)); + return context(particle); + } + + private static DustOptions colorAt(GradientModifier gradient, ParticleRenderContext ctx, double x) { + ctx.reset(); + ctx.x = x; + gradient.modify(ctx); + assertEquals(org.bukkit.Particle.DUST, ctx.particle); + return assertInstanceOf(DustOptions.class, ctx.data); + } + + /** The table has 256 entries, so a stop between two entries is only hit to within a step or so. */ + private static void assertNearColor(Color expected, Color actual) { + assertTrue(Math.abs(expected.getRed() - actual.getRed()) <= 2 + && Math.abs(expected.getGreen() - actual.getGreen()) <= 2 + && Math.abs(expected.getBlue() - actual.getBlue()) <= 2, + "expected about " + expected + " but was " + actual); + } + + private static GradientModifier blackToWhite() { + GradientModifier gradient = new GradientModifier(BLACK, WHITE, StandardInput.X); + gradient.prepare(BOUNDS); + return gradient; + } + + @Test + void colorsTheEndsWithTheEndColors() { + GradientModifier gradient = blackToWhite(); + ParticleRenderContext ctx = flame(); + assertEquals(BLACK, colorAt(gradient, ctx, 0).getColor()); + assertEquals(WHITE, colorAt(gradient, ctx, 10).getColor()); + assertFalse(ctx.isDefaultParticle()); + } + + @Test + void blendsAlongTheInput() { + GradientModifier gradient = blackToWhite(); + ParticleRenderContext ctx = flame(); + int previous = -1; + for (int x = 0; x <= 10; x++) { + int red = colorAt(gradient, ctx, x).getColor().getRed(); + assertTrue(red > previous, "the gradient should get brighter along x, at x=" + x); + previous = red; + } + // the table has 256 entries, so the midpoint lands within one step of the exact blend + assertEquals(127.5, colorAt(gradient, ctx, 5).getColor().getRed(), 1.5); + } + + @Test + void appliesItsEasing() { + GradientModifier gradient = blackToWhite(); + gradient.setEasing(EasingFunction.QUAD_IN); + gradient.prepare(BOUNDS); + // QUAD_IN(0.5) = 0.25 + assertEquals(255 * 0.25, colorAt(gradient, flame(), 5).getColor().getRed(), 1.5); + } + + @Test + void usesSizeOneForParticlesThatAreNotDust() { + assertEquals(1f, colorAt(blackToWhite(), flame(), 3).getSize()); + } + + @Test + void keepsTheSizeOfDustParticles() { + GradientModifier gradient = blackToWhite(); + assertEquals(2.5f, colorAt(gradient, dust(2.5f), 3).getSize()); + // the table is rebuilt when a draw with another size reuses the gradient + assertEquals(0.5f, colorAt(gradient, dust(0.5f), 3).getSize()); + assertEquals(WHITE, colorAt(gradient, dust(0.5f), 10).getColor()); + } + + @Test + void reusesItsDustOptionsInsteadOfAllocatingPerPoint() { + GradientModifier gradient = blackToWhite(); + ParticleRenderContext ctx = flame(); + assertSame(colorAt(gradient, ctx, 4), colorAt(gradient, ctx, 4)); + } + + @Test + void worksWithoutPrepare() { + GradientModifier gradient = new GradientModifier(BLACK, WHITE, StandardInput.X); + assertEquals(WHITE, colorAt(gradient, flame(), 10).getColor()); + } + + @Test + void addedStopsAreSortedAndUsedAfterPrepare() { + GradientModifier gradient = blackToWhite(); + gradient.addStop(0.5, Color.RED); + gradient.addStop(0.25, Color.BLUE); + assertEquals(List.of(0.0, 0.25, 0.5, 1.0), gradient.getStops().stream().map(ColorStop::position).toList()); + gradient.prepare(BOUNDS); + ParticleRenderContext ctx = flame(); + assertNearColor(Color.RED, colorAt(gradient, ctx, 5).getColor()); + assertNearColor(Color.BLUE, colorAt(gradient, ctx, 2.5).getColor()); + } + + @Test + void multiStopConstructor() { + GradientModifier gradient = new GradientModifier(List.of( + new ColorStop(0, Color.RED), new ColorStop(0.5, Color.LIME), new ColorStop(1, Color.BLUE)), StandardInput.X); + gradient.prepare(BOUNDS); + ParticleRenderContext ctx = flame(); + assertEquals(Color.RED, colorAt(gradient, ctx, 0).getColor()); + assertNearColor(Color.LIME, colorAt(gradient, ctx, 5).getColor()); + assertEquals(Color.BLUE, colorAt(gradient, ctx, 10).getColor()); + } + + @Test + void clone_isConfiguredTheSameButIndependent() { + GradientModifier gradient = blackToWhite(); + gradient.setEasing(EasingFunction.SINE_OUT); + GradientModifier copy = (GradientModifier) gradient.clone(); + assertNotSame(gradient, copy); + assertEquals(gradient.modifierHash(), copy.modifierHash()); + assertEquals(gradient.getInput(), copy.getInput()); + assertEquals(gradient.getStops(), copy.getStops()); + copy.prepare(BOUNDS); + ParticleRenderContext ctx = flame(); + assertEquals(colorAt(gradient, ctx, 3).getColor(), colorAt(copy, ctx, 3).getColor()); + + copy.addStop(0.5, Color.RED); + copy.setInput(StandardInput.Y); + assertEquals(2, gradient.getStops().size(), "the original's stops must not change"); + assertEquals(StandardInput.X, gradient.getInput()); + } + + @Test + void modifierHash_changesWithTheConfiguration() { + int base = blackToWhite().modifierHash(); + GradientModifier stop = blackToWhite(); + stop.addStop(0.5, Color.RED); + GradientModifier eased = blackToWhite(); + eased.setEasing(EasingFunction.CUBIC_IN); + GradientModifier input = blackToWhite(); + input.setInput(StandardInput.RADIUS); + GradientModifier colors = new GradientModifier(BLACK, Color.RED, StandardInput.X); + assertNotEquals(base, stop.modifierHash()); + assertNotEquals(base, eased.modifierHash()); + assertNotEquals(base, input.modifierHash()); + assertNotEquals(base, colors.modifierHash()); + assertEquals(base, blackToWhite().modifierHash()); + } + + @Test + void needsARenderContext() { + assertEquals(ParticleRenderContext.class, blackToWhite().contextType()); + } +} diff --git a/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifierTest.java b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifierTest.java new file mode 100644 index 0000000..57e74f7 --- /dev/null +++ b/skript-particle/src/test/java/com/sovdee/skriptparticles/rendering/shaders/MotionModifierTest.java @@ -0,0 +1,196 @@ +package com.sovdee.skriptparticles.rendering.shaders; + +import com.destroystokyo.paper.ParticleBuilder; +import com.sovdee.skriptparticles.rendering.ParticleRenderContext; +import com.sovdee.skriptparticles.rendering.shaders.MotionModifier.Mode; +import org.joml.Quaterniond; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class MotionModifierTest { + + private static final double EPS = 1e-5; + + private static ParticleRenderContext context() { + return new ParticleRenderContext(new ParticleBuilder(org.bukkit.Particle.FLAME)); + } + + private static Vector3d motion(MotionModifier modifier, ParticleRenderContext ctx, double x, double y, double z) { + ctx.reset(); + ctx.x = x; + ctx.y = y; + ctx.z = z; + modifier.modify(ctx); + return new Vector3d(ctx.motionX, ctx.motionY, ctx.motionZ); + } + + private static Vector3d motion(Mode mode, double x, double y, double z) { + MotionModifier modifier = new MotionModifier(mode); + modifier.prepare(null); + return motion(modifier, context(), x, y, z); + } + + private static void assertVector(Vector3d expected, Vector3d actual) { + assertTrue(expected.equals(actual, EPS), "expected " + expected + " but was " + actual); + } + + @Test + void outwards_pointsAwayFromTheCenter() { + assertVector(new Vector3d(0.6, 0, 0.8), motion(Mode.OUTWARDS, 3, 0, 4)); + assertVector(new Vector3d(0, -1, 0), motion(Mode.OUTWARDS, 0, -7, 0)); + } + + @Test + void inwards_pointsTowardTheCenter() { + assertVector(new Vector3d(-0.6, 0, -0.8), motion(Mode.INWARDS, 3, 0, 4)); + } + + @Test + void radialModes_atTheCenter_haveNoMotion() { + assertVector(new Vector3d(), motion(Mode.OUTWARDS, 0, 0, 0)); + assertVector(new Vector3d(), motion(Mode.INWARDS, 0, 0, 0)); + } + + @Test + void orbits_areUnitTangentsAroundTheYAxis() { + double[][] points = {{1, 0, 0}, {0, 2, 3}, {-4, -1, 1}, {2.5, 7, -2.5}}; + for (Mode mode : new Mode[]{Mode.CLOCKWISE, Mode.COUNTERCLOCKWISE}) { + for (double[] p : points) { + Vector3d m = motion(mode, p[0], p[1], p[2]); + assertEquals(1, m.length(), EPS, mode + " should be a unit vector"); + assertEquals(0, m.y, EPS, mode + " should stay horizontal"); + assertEquals(0, m.dot(p[0], 0, p[2]), EPS, mode + " should be perpendicular to the radius"); + } + } + } + + @Test + void clockwise_isTheReverseOfCounterclockwise() { + assertVector(motion(Mode.COUNTERCLOCKWISE, 2, 1, -3).negate(), motion(Mode.CLOCKWISE, 2, 1, -3)); + } + + @Test + void counterclockwise_turnsFromPlusXTowardMinusZ() { + // cross(+Y, +X) = -Z: counterclockwise when looking down from above in Minecraft's axes + assertVector(new Vector3d(0, 0, -1), motion(Mode.COUNTERCLOCKWISE, 1, 0, 0)); + assertVector(new Vector3d(-1, 0, 0), motion(Mode.COUNTERCLOCKWISE, 0, 0, -1)); + } + + @Test + void orbits_onTheAxis_haveNoMotion() { + Vector3d m = motion(Mode.CLOCKWISE, 0, 5, 0); + assertTrue(m.isFinite()); + assertEquals(0, m.length(), EPS); + } + + @Test + void orbits_followTheShapesOrientation() { + MotionModifier modifier = new MotionModifier(Mode.COUNTERCLOCKWISE); + modifier.prepare(null); + ParticleRenderContext ctx = context(); + // rotating 90 degrees about X turns the shape's Y axis into +Z + ctx.orientation = new Quaterniond().rotateX(Math.PI / 2); + Vector3d m = motion(modifier, ctx, 1, 0, 0); + assertVector(new Vector3d(0, 1, 0), m); + assertEquals(0, m.dot(0, 0, 1), EPS, "the orbit should be around the rotated axis"); + } + + @Test + void prepare_picksUpANewOrientation() { + MotionModifier modifier = new MotionModifier(Mode.COUNTERCLOCKWISE); + ParticleRenderContext ctx = context(); + modifier.prepare(null); + assertVector(new Vector3d(0, 0, -1), motion(modifier, ctx, 1, 0, 0)); + + ctx.orientation = new Quaterniond().rotateX(Math.PI / 2); + modifier.prepare(null); + assertVector(new Vector3d(0, 1, 0), motion(modifier, ctx, 1, 0, 0)); + } + + @Test + void setsHasMotion_exceptForNone() { + for (Mode mode : Mode.values()) { + MotionModifier modifier = new MotionModifier(mode); + modifier.prepare(null); + ParticleRenderContext ctx = context(); + motion(modifier, ctx, 1, 2, 3); + assertEquals(mode != Mode.NONE, ctx.hasMotion, mode.toString()); + } + assertVector(new Vector3d(), motion(Mode.NONE, 1, 2, 3)); + } + + @Test + void clone_andHash() { + Set hashes = new HashSet<>(); + for (Mode mode : Mode.values()) { + MotionModifier modifier = new MotionModifier(mode); + MotionModifier copy = modifier.clone(); + assertNotSame(modifier, copy); + assertEquals(mode, copy.getMode()); + assertEquals(modifier.modifierHash(), copy.modifierHash()); + hashes.add(modifier.modifierHash()); + } + assertEquals(Mode.values().length, hashes.size()); + } + + private static Vector3d motion(Mode mode, Vector3d velocity, Quaterniond orientation, double x, double y, double z) { + MotionModifier modifier = new MotionModifier(mode, velocity); + modifier.prepare(null); + ParticleRenderContext ctx = context(); + ctx.orientation = orientation; + return motion(modifier, ctx, x, y, z); + } + + @Test + void globalVelocity_isTheSameEverywhere() { + Vector3d velocity = new Vector3d(0.5, 2, -1); + Quaterniond tilted = new Quaterniond().rotateX(Math.PI / 2); + assertVector(velocity, motion(Mode.GLOBAL, velocity, null, 3, 0, 4)); + assertVector(velocity, motion(Mode.GLOBAL, velocity, tilted, -1, 5, 2)); + } + + @Test + void relativeVelocity_usesOutwardsUpAndAround() { + // at +x: outwards is +x, up is +y, and counterclockwise is -z + assertVector(new Vector3d(2, 0, 0), motion(Mode.RELATIVE, new Vector3d(2, 0, 0), null, 3, 1, 0)); + assertVector(new Vector3d(0, 3, 0), motion(Mode.RELATIVE, new Vector3d(0, 3, 0), null, 3, 1, 0)); + assertVector(new Vector3d(0, 0, -1), motion(Mode.RELATIVE, new Vector3d(0, 0, 1), null, 3, 1, 0)); + // at +z: outwards is +z, and counterclockwise is +x + assertVector(new Vector3d(1, 0, -2), motion(Mode.RELATIVE, new Vector3d(-2, 0, 1), null, 0, 0, 5)); + } + + @Test + void relativeAround_matchesCounterclockwise() { + double[][] points = {{1, 0, 0}, {0, 2, 3}, {-4, -1, 1}}; + for (double[] p : points) + assertVector(motion(Mode.COUNTERCLOCKWISE, p[0], p[1], p[2]), + motion(Mode.RELATIVE, new Vector3d(0, 0, 1), null, p[0], p[1], p[2])); + } + + @Test + void relativeVelocity_followsTheShapesOrientation() { + // tipped over so the shape's y axis points along +x: "up" is now +x, and a point at +y is "outwards" along +y + Quaterniond tipped = new Quaterniond().rotateZ(-Math.PI / 2); + assertVector(new Vector3d(1, 0, 0), motion(Mode.RELATIVE, new Vector3d(0, 1, 0), tipped, 0, 2, 0)); + assertVector(new Vector3d(0, 1, 0), motion(Mode.RELATIVE, new Vector3d(1, 0, 0), tipped, 0, 2, 0)); + } + + @Test + void relativeVelocity_onTheAxis_onlyMovesAlongIt() { + assertVector(new Vector3d(0, 1, 0), motion(Mode.RELATIVE, new Vector3d(5, 1, 5), null, 0, 3, 0)); + } + + @Test + void velocity_isCopiedWithTheModifier() { + MotionModifier original = new MotionModifier(Mode.RELATIVE, new Vector3d(1, 2, 3)); + MotionModifier copy = original.clone(); + original.setVelocity(new Vector3d(0, 0, 0)); + assertVector(new Vector3d(1, 2, 3), copy.getVelocity()); + assertEquals(Mode.RELATIVE, copy.getMode()); + } +} diff --git a/skript-particle/src/test/resources/particle-testing.sk b/skript-particle/src/test/resources/particle-testing.sk new file mode 100644 index 0000000..9b4750a --- /dev/null +++ b/skript-particle/src/test/resources/particle-testing.sk @@ -0,0 +1,364 @@ +command /full-test: + trigger: + set {_loc} to player's location + + # --- Shape construction --- + set {_shapes::circle} to a circle with radius 1 + set {_shapes::arc} to a arc with radius 1 and angle 60 degrees + set offset vector of {_shapes::arc} to vector(3, 0, 0) + set {_shapes::cylinder} to a tube with radius 1 and height 1 + set offset vector of {_shapes::cylinder} to vector(6, 0, 0) + set {_shapes::cylarc} to a arc with radius 1, height 1, and angle 60 degrees + set offset vector of {_shapes::cylarc} to vector(9, 0, 0) + set {_shapes::helix} to a helix with radius 1, height 1, and winding rate of 1 + set offset vector of {_shapes::helix} to vector(12, 0, 0) + + set {_shapes::ellipse} to an ellipse with radius 0.5 and 1 + set offset vector of {_shapes::ellipse} to vector(0, 0, 3) + set {_shapes::elliptical-arc} to an elliptical arc with radius 0.5, 1 and angle 60 degrees + set offset vector of {_shapes::elliptical-arc} to vector(3, 0, 3) + set {_shapes::elliptical-cylinder} to an elliptical tube with radius 0.5, 1 and height 1 + set offset vector of {_shapes::elliptical-cylinder} to vector(6, 0, 3) + set {_shapes::elliptical-cylarc} to an elliptical arc with radius 0.5, 1, height 1 and angle 60 degrees + set offset vector of {_shapes::elliptical-cylarc} to vector(9, 0, 3) + + set {_shapes::sphere} to a sphere with radius 1 + set offset vector of {_shapes::sphere} to vector(0, 0, 6) + set {_shapes::cap} to a spherical cap with radius 1 and cutoff angle 60 degrees + set offset vector of {_shapes::cap} to vector(3, 0, 6) + set {_shapes::ellipsoid} to an ellipsoid with radius 1, 0.5, and 0.8 + set offset vector of {_shapes::ellipsoid} to vector(6, 0, 6) + set {_shapes::ellipsoid-2} to a hollow ellipsoid with radius 1, 0.5, and 0.8 + set offset vector of {_shapes::ellipsoid-2} to vector(9, 0, 6) + + set {_shapes::rectangle} to a rectangle with length 1 and width 1 + set offset vector of {_shapes::rectangle} to vector(0, 0, 9) + set {_shapes::cuboid} to a cuboid with length 1, width 1 and height 1 + set offset vector of {_shapes::cuboid} to vector(3, 0, 9) + + set {_shapes::line-1} to a line from vector(0, 0, 0) to vector(1, 0, 0) + set {_shapes::line-2} to a line from {_loc} to {_loc} offset by vector(0, 1, 0) + set {_shapes::line-3} to a line in direction vector(0, 0, 1) with length 1 + set offset vector of {_shapes::line-1}, {_shapes::line-2}, {_shapes::line-3} to vector(0, 0, -3) + set {_shapes::bezier} to a curve from vector(0, 0, 0) to vector(2, 0, 0) using control points vector(1, 1, 0) + set offset vector of {_shapes::bezier} to vector(6, 0, -3) + set {_shapes::heart} to a solid heart with width 2 and length 2 + set offset vector of {_shapes::heart} to vector(3, 0, -3) + set {_shapes::star} to a solid star with 5 points, inner radius 0.5, and outer radius 1 + set offset vector of {_shapes::star} to vector(9, 0, -3) + + set {_shapes::polygon-1} to a triangle with radius 1 + set offset vector of {_shapes::polygon-1} to vector(0, 0, -6) + set {_shapes::polygon-2} to a square with side length 1 + set offset vector of {_shapes::polygon-2} to vector(3, 0, -6) + set {_shapes::polygon-3} to a regular polygon with 5 sides and side length 0.6 + set offset vector of {_shapes::polygon-3} to vector(6, 0, -6) + set {_shapes::polygon-4} to a regular polygon with 12 sides and radius 1 + set offset vector of {_shapes::polygon-4} to vector(9, 0, -6) + + set {_shapes::ir-polygon} to a polygon with points vector(0, 0, 0), vector(1, 0, 0), vector(1, 1, 1) + set offset vector of {_shapes::ir-polygon} to vector(0, 0, -9) + set {_shapes::polyhedron} to an icosahedron with radius 1 + set offset vector of {_shapes::polyhedron} to vector(3, 0, -9) + + set particle of {_shapes::*} to electric spark particle with extra value 0 + show local and global axes of {_shapes::*} + + # --- Draw section body: counts per-shape callbacks --- + set {_drawCount} to 0 + loop 40 times: + draw shapes {_shapes::*} at {_loc}: + add 1 to {_drawCount} + wait 1 tick + broadcast "Drawn shapes: %({_drawCount} / 40)%" + + # --- Radius / inner+outer radius --- + loop 20 times: + add 0.1 to radius of {_shapes::*} + add 0.1 to inner radius of {_shapes::*} + add 0.1 to outer radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 20 times: + add -0.1 to radius of {_shapes::*} + add -0.1 to inner radius of {_shapes::*} + add -0.1 to outer radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Scale --- + loop 20 times: + add 0.1 to shape scale of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 20 times: + add -0.1 to shape scale of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Rotation: global axes --- + loop 40 times: + rotate shapes {_shapes::*} around vector(0, 1, 0) by 9 degrees + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + rotate shapes {_shapes::*} around vector(1, 0, 0) by 9 degrees + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + rotate shapes {_shapes::*} around vector(0, 0, 1) by 9 degrees + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Rotation: quaternion --- + set {_rotation} to rotation around vector(1, 1, 1) by 9 degrees + loop 40 times: + rotate shapes {_shapes::*} by {_rotation} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Rotation: local axis --- + rotate shapes {_shapes::*} around vector(1, 0, 0) by 45 degrees + loop 40 times: + rotate shapes {_shapes::*} around local z axis by 9 degrees + draw shapes {_shapes::*} at {_loc} + wait 1 tick + reset normal vector of {_shapes::*} + + # --- Cutoff angle / winding rate --- + loop 40 times: + add 7.5 to the cutoff angle of {_shapes::*} + add 0.1 to the winding rate of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + add -7.5 to the cutoff angle of {_shapes::*} + remove 0.1 from the winding rate of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Length / side length / outer radius --- + loop 40 times: + add 0.1 to the shape length of {_shapes::*} + add 0.01 to the side length of {_shapes::*} + add 0.1 to the outer radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + add -0.1 to the shape length of {_shapes::*} + add -0.01 to the side length of {_shapes::*} + add -0.1 to the outer radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Width / inner radius --- + loop 40 times: + add 0.1 to the shape width of {_shapes::*} + add 0.1 to the inner radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + add -0.1 to the shape width of {_shapes::*} + add -0.1 to the inner radius of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Height --- + loop 40 times: + add 0.05 to the shape height of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 40 times: + add -0.05 to the shape height of {_shapes::*} + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Sides / star points --- + loop 4 times: + add 1 to the sides of {_shapes::*} + add 1 to the star points of {_shapes::*} + loop 10 times: + draw shapes {_shapes::*} at {_loc} + wait 1 tick + loop 4 times: + remove 1 from the sides of {_shapes::*} + remove 1 from the star points of {_shapes::*} + loop 10 times: + draw shapes {_shapes::*} at {_loc} + wait 1 tick + + # --- Style cycling --- + set style of {_shapes::*} to surface + loop 10 times: + draw shapes {_shapes::*} at {_loc} + wait 1 tick + set style of {_shapes::*} to filled + loop 10 times: + draw shapes {_shapes::*} at {_loc} + wait 1 tick + set style of {_shapes::*} to outline + + # --- Particle density --- + set point count of {_shapes::*} to 50 + loop 10 times: + draw shapes {_shapes::*} at {_loc} + wait 1 tick + reset point count of {_shapes::*} + + # --- Timed draw + animation ordering --- + set the animation order of {_shapes::*} to lowest-to-highest + draw shapes {_shapes::*} at {_loc} for 3 seconds with refresh 1 tick + wait 4 ticks + set the animation order of {_shapes::*} to highest-to-lowest + draw shapes {_shapes::*} at {_loc} for 3 seconds with refresh 1 tick + wait 4 ticks + set the animation order of {_shapes::*} to default + + hide local and global axes of {_shapes::*} + +command /magic: + trigger: + set {_shapes::outer-circle} to a circle of radius 2.225 + set {_shapes::inner-circle} to a circle of radius 2 + set {_shapes::tiny-circle} to a circle of radius 0.875 + + set {_shapes::triangle-1} to a triangle with radius 2 + set {_shapes::triangle-2} to a triangle with radius 2 + rotate shapes {_shapes::triangle-2} around y axis by 60 degrees + + set particle of {_shapes::*} to electric spark particle + + loop 200 times: + set {_view} to vector from yaw player's yaw and pitch player's pitch + set {_yaw} to yaw of {_view} + # figure out the rotation needed to rotate the shape + set {_rotation} to rotation from vector(0, 1, 0) to {_view} + draw shapes {_shapes::*} at player's eye location ~ {_view}: + # only happens for this draw call, the original shape is not modified + # note that this is called once for each shape, hence `drawn shape` and not `drawn shapes` + rotate drawn shape by {_rotation} + # the shape takes the shortest path to rotate to the desired rotation, but we don't want that here + # (we want the shape to be the same, no matter where we look) + # so we'll correct for it by rotating the shape around the y axis by the yaw of the view + rotate shape drawn shape around relative y axis by -1 * {_yaw} + + wait 1 tick + +command /star: + trigger: + set {_star} to a star with 5 points, inner radius 1, and outer radius 2 + set particle of {_star} to electric spark particle + loop 40 times: + draw shape {_star} at player + rotate shape {_star} around y axis by 9 degrees + wait 2 ticks + +command /modifier-test: + trigger: + set {_loc} to player's location + + # 1. Taper modifier on a sphere (along Y: scales XZ) + set {_sphere} to a sphere with radius 1 + set particle of {_sphere} to electric spark particle with extra value 0 + add taper from 0.0 to 1.0 along the y axis to modifiers of {_sphere} + draw shape {_sphere} at {_loc} for 5 seconds with refresh 2 ticks + wait 1 tick + + # 1b. Taper along X axis (scales YZ) + set {_sphere1b} to a sphere with radius 1 + set particle of {_sphere1b} to electric spark particle with extra value 0 + add taper from 0.0 to 1.0 along the x axis to modifiers of {_sphere1b} + draw shape {_sphere1b} at {_loc} ~ vector(0, 0, -4) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 2. Twist modifier on a cylinder along Y (rotates XZ plane) + set {_cyl} to a hollow cuboid with length 1, width 1, and height 2 + set particle of {_cyl} to electric spark particle with extra value 0 + add a twist of 180 degrees along the y axis to modifiers of {_cyl} + draw shape {_cyl} at {_loc} ~ vector(4, 0, 0) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 2b. Twist along X axis (rotates YZ plane) + set {_cyl2b} to a hollow cuboid with length 2, width 1, and height 1 + set particle of {_cyl2b} to electric spark particle with extra value 0 + add a twist of 180 degrees along the x axis to modifiers of {_cyl2b} + draw shape {_cyl2b} at {_loc} ~ vector(4, 0, -4) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 3. Wave modifier on a rectangle + set {_rect} to a solid rectangle with length 2 and width 2 + set particle of {_rect} to electric spark particle with extra value 0 + add a wave with amplitude 0.5 and frequency 4 along the x axis displacing the y axis to modifiers of {_rect} + add a wave with amplitude 0.5 and frequency 4 along the z axis displacing the y axis to modifiers of {_rect} + draw shape {_rect} at {_loc} ~ vector(8, 0, 0) for 5 seconds with refresh 2 ticks + wait 1 tick + + # stop + + # 4. Axis gradient (red→blue along Y) on a cuboid + set {_cuboid} to a cuboid with length 1, width 1 and height 2 + set particle of {_cuboid} to white dust particle + add gradient from red to blue along the y axis to modifiers of {_cuboid} + draw shape {_cuboid} at {_loc} ~ vector(0, 0, 4) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 5. Radial gradient (yellow→green) on a disc + set {_disc} to a disc with radius 1.5 + set particle of {_disc} to white dust particle + add radial gradient from yellow to green to modifiers of {_disc} + draw shape {_disc} at {_loc} ~ vector(4, 0, 4) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 6. Angular gradient (cyan→magenta) on a circle + set {_circle} to a circle with radius 1.5 + set particle of {_circle} to white dust particle + add angular gradient from cyan to magenta to modifiers of {_circle} + draw shape {_circle} at {_loc} ~ vector(8, 0, 4) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 7. Spherical gradient (white→black) on a sphere + set {_sphere2} to a solid sphere with radius 1 + set particle of {_sphere2} to white dust particle + add spherical gradient from white to black to modifiers of {_sphere2} + draw shape {_sphere2} at {_loc} ~ vector(0, 0, 8) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 8. Multi-stop gradient (red→orange→yellow→white) on a cylinder + set {_gradcyl} to a tube with radius 1 and height 2 + set particle of {_gradcyl} to white dust particle + set {_grad} to gradient from red, orange, yellow, white along the y axis + add {_grad} to modifiers of {_gradcyl} + draw shape {_gradcyl} at {_loc} ~ vector(4, 0, 8) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 9. Motion modifier (outwards) on a sphere + set {_sphere3} to a sphere with radius 1 + set particle of {_sphere3} to electric spark particle with extra value 0.3 + add motion modifier outwards to modifiers of {_sphere3} + draw shape {_sphere3} at {_loc} ~ vector(8, 0, 8) for 5 seconds with refresh 2 ticks + wait 1 tick + + # 11. Combined modifiers: taper + wave + axis gradient on one shape + set {_combined} to a tube with radius 1 and height 2 + set particle of {_combined} to white dust particle + add taper from 0.2 to 1.0 along the y axis to modifiers of {_combined} + add a wave with amplitude 0.3 and frequency 3 along the draw order displacing the y axis to modifiers of {_combined} + add gradient from orange to cyan along the y axis to modifiers of {_combined} + draw shape {_combined} at {_loc} ~ vector(4, 0, 12) for 5 seconds with refresh 2 ticks + + # 10. Modifier management: add, remove, delete + set {_managed} to a circle with radius 1 + set particle of {_managed} to white dust particle + set {_taper} to a taper from 0 to 1 + add {_taper} to modifiers of {_managed} + draw shape {_managed} at {_loc} ~ vector(0, 0, 12) + wait 1 second + remove {_taper} from modifiers of {_managed} + add gradient from red to blue along the y axis to modifiers of {_managed} + draw shape {_managed} at {_loc} ~ vector(0, 0, 12) + wait 1 second + delete modifiers of {_managed} + draw shape {_managed} at {_loc} ~ vector(0, 0, 12) + wait 1 second + + broadcast "Modifier test complete!" diff --git a/skript-particle/src/test/scripts/customshader.sk b/skript-particle/src/test/scripts/customshader.sk new file mode 100644 index 0000000..a31d42a --- /dev/null +++ b/skript-particle/src/test/scripts/customshader.sk @@ -0,0 +1,63 @@ +# Tests for the custom point modifier (shader) section and its point expressions. + +test "custom point modifier section parses and creates a modifier": + set {_shader} to a custom point modifier: + add 0.5 to point's y + if mod(point's index, 2) = 0: + set point's visibility to false + assert {_shader} is set with "custom point modifier section did not produce a modifier" + + set {_c} to a circle with radius 3 + add {_shader} to modifiers of {_c} + assert size of modifiers of {_c} is 1 with "custom modifier was not added to the shape" + +test "point expressions parse inside the section": + set {_shader} to a custom point modifier: + set {_x} to point's x + set {_i} to point's index + set {_n} to the point count + set point's z to {_x} * 2 + assert {_shader} is set with "section using point expressions did not produce a modifier" + +# point expressions must be rejected outside a custom point modifier section +parse: + results: {sp::parse::outside::*} + code: + on load: + set {_x} to point's x + +test "point expressions error outside the section": + set {_found} to false + loop {sp::parse::outside::*}: + if loop-value contains "custom point modifier": + set {_found} to true + assert {_found} is true with "expected a parse error using point expressions outside the section, got: %{sp::parse::outside::*}%" + delete {sp::parse::outside::*} + +# delays must be rejected inside the section +parse: + results: {sp::parse::delay::*} + code: + on load: + set {_s} to a custom point modifier: + wait 1 tick + set point's visibility to false + +test "delays are rejected inside the section": + set {_found} to false + loop {sp::parse::delay::*}: + if loop-value contains "delays can't be used": + set {_found} to true + assert {_found} is true with "expected a parse error for a delay inside the section, got: %{sp::parse::delay::*}%" + delete {sp::parse::delay::*} + +# the expression requires a section +parse: + results: {sp::parse::nosection::*} + code: + on load: + set {_s} to a custom point modifier + +test "custom point modifier without a section errors": + assert size of {sp::parse::nosection::*} is greater than 0 with "expected a parse error using the expression without a section, got: %{sp::parse::nosection::*}%" + delete {sp::parse::nosection::*} diff --git a/skript-particle/src/test/scripts/docexamples.sk b/skript-particle/src/test/scripts/docexamples.sk new file mode 100644 index 0000000..92259ca --- /dev/null +++ b/skript-particle/src/test/scripts/docexamples.sk @@ -0,0 +1,928 @@ +# GENERATED by src/test/gen_doc_examples.py - do not edit. Every documentation example must parse. +# Regenerate after changing any @Example (from the repository root): +# python skript-particle/src/test/gen_doc_examples.py skript-particle/src/main/java/com/sovdee/skriptparticles/skript skript-particle/src/test/scripts/docexamples.sk + +parse: + results: {sp::doc::ExprDrawnShapes::*} + code: + command /spdoc-exprdrawnshapes: + trigger: + draw the shapes {_shapes::*} at player's head: + # only affects the drawn version, the original shape is not changed + set radius of drawn shape to 2 + +parse: + results: {sp::doc::EffSecDrawShape::*} + code: + command /spdoc-effsecdrawshape: + trigger: + draw the shape (sphere with radius 1) at player's location + draw the shapes of (a sphere with radius 1 and a circle with radius 2) at player's location to (all players in radius 10 around player) + synchronously draw the shape of a sphere with radius 1 at player's location + draw the shape of a circle with radius 3 at player for 5 seconds with refresh rate of 2 ticks + draw the shape {_shape} at player's location: + set event-shape's particle to red dust particle + synchronously draw the shapes {_shapes::*} at player's location: + set event-shape's radius to 2 + +parse: + results: {sp::doc::EffSecDrawShapeAnimation::*} + code: + command /spdoc-effsecdrawshapeanimation: + trigger: + draw an animation of the shape of a circle with radius 3 at player over 2 seconds + draw an animated shape of {_helix} at player to all players over 1 second: + set particle of drawn shape to red dust particle + +parse: + results: {sp::doc::ExprGradientModifier::*} + code: + command /spdoc-exprgradientmodifier: + trigger: + add a gradient from red to blue along the y axis to modifiers of {_shape} + add a colour gradient from red, yellow, green and blue along the angle to modifiers of {_shape} + add a radial gradient of white and black to modifiers of {_shape} + set modifiers of {_shape} to a spherical gradient of yellow and orange + set {_gradient} to a gradient of red and blue along the draw order: + set modifier easing to ease in-out sine + add color stop at 0.5 colored white to the new modifier + +parse: + results: {sp::doc::ExprMotionModifier::*} + code: + command /spdoc-exprmotionmodifier: + trigger: + add a motion modifier counterclockwise to modifiers of {_shape} + add a motion modifier inwards to modifiers of {_shape} + add a motion modifier with velocity vector(0, 1, 0) to modifiers of {_shape} + # souls that spiral inwards and up, like water going down a drain in reverse + set particle of {_ring} to sculk soul particle with extra value 0.1 + add a motion modifier with relative velocity vector(-1, 0.5, 2) to modifiers of {_ring} + +parse: + results: {sp::doc::ExprScaleModifier::*} + code: + command /spdoc-exprscalemodifier: + trigger: + add a scale from 1 to 0 along the y axis to modifiers of {_shape} + add a scale from 1 to 2 along the radius scaling the xyz axes to modifiers of {_shape} + set {_taper} to a scale from 1 to 0 along the y axis: + set modifier easing to ease in quadratic + add {_taper} to modifiers of {_shape} + +parse: + results: {sp::doc::ExprTwistModifier::*} + code: + command /spdoc-exprtwistmodifier: + trigger: + add a twist of 360 degrees to modifiers of {_shape} + add a twist of 180 degrees along the radius in the xy plane to modifiers of {_shape} + add a twist of 3.14 radians to modifiers of {_shape} + +parse: + results: {sp::doc::ExprWaveModifier::*} + code: + command /spdoc-exprwavemodifier: + trigger: + add a wave with amplitude 0.5 and frequency 2 to modifiers of {_shape} + add a wave with amplitude 0.3 and frequency 4 along the x axis displacing the y axis to modifiers of {_shape} + add a wave with amplitude 0.5 and frequency 1 along the angle to modifiers of {_shape}: + set wave phase to 90 + +parse: + results: {sp::doc::EffAddColorStop::*} + code: + command /spdoc-effaddcolorstop: + trigger: + add color stop at 0 colored red to {_gradient} + add stop at 0.5 colored yellow to {_gradient} + add color stop at 1 colored blue to {_gradient} + +parse: + results: {sp::doc::ExprEasing-functions::*} + code: + function myEase(t: number) :: number: + return {_t} ^ 3 + +parse: + results: {sp::doc::ExprEasing::*} + code: + command /spdoc-expreasing: + trigger: + set {_e} to linear easing + set {_e} to ease in quadratic + set {_e} to ease out cubic + set {_e} to ease in-out sine + set {_e} to power ease in with exponent 4 + set {_e} to power ease out with exponent 0.5 + add a scale from 0 to 1 along the y axis to modifiers of {_shape}: + set modifier easing to ease in quadratic + add a gradient from red to blue along the y axis to modifiers of {_shape}: + set modifier easing to ease in-out sine + set {_e} to custom easing from function "myEase" + +parse: + results: {sp::doc::ExprNewModifier::*} + code: + command /spdoc-exprnewmodifier: + trigger: + set {_wave} to a wave with amplitude 0.3 and frequency 2 along the y axis: + set wave phase of the new modifier to 90 + set modifier easing to ease in-out sine + +parse: + results: {sp::doc::ExprPointCoordinate::*} + code: + command /spdoc-exprpointcoordinate: + trigger: + set {_shader} to a custom shader: + set the point's y to point's y + 1 + add 0.5 to the x of point + +parse: + results: {sp::doc::ExprPointCount::*} + code: + command /spdoc-exprpointcount: + trigger: + set {_shader} to a custom shader: + set {_t} to point's index / point count + +parse: + results: {sp::doc::ExprPointDisplacement::*} + code: + command /spdoc-exprpointdisplacement: + trigger: + set {_shader} to a custom shader: + add 0.5 to the point's y displacement + set the x displacement of point to 1 + +parse: + results: {sp::doc::ExprPointIndex::*} + code: + command /spdoc-exprpointindex: + trigger: + set {_shader} to a custom shader: + set {_t} to point's index / point count + +parse: + results: {sp::doc::ExprPointMotion::*} + code: + command /spdoc-exprpointmotion: + trigger: + set {_shader} to a custom shader: + set the point's y motion to 0.5 + set the x motion of point to point's x / 2 + +parse: + results: {sp::doc::ExprPointScale::*} + code: + command /spdoc-exprpointscale: + trigger: + set {_shader} to a custom shader: + set {_s} to point's scale + +parse: + results: {sp::doc::ExprPointVisible::*} + code: + command /spdoc-exprpointvisible: + trigger: + set {_shader} to a custom shader: + set point's visibility to false + if point's visibility is true: + add 1 to point's y + +parse: + results: {sp::doc::ExprGradientColors::*} + code: + command /spdoc-exprgradientcolors: + trigger: + set gradient colours of {_gradient} to red, white and blue + add black to {_gradient}'s gradient colors + send "%gradient colours of {_gradient}%" + +parse: + results: {sp::doc::ExprModifierEasing::*} + code: + command /spdoc-exprmodifiereasing: + trigger: + set modifier easing of {_taper} to ease in quadratic + set {_twist} to a twist of 360 degrees along the y axis: + set modifier easing to ease in-out sine + reset modifier easing of {_twist} + +parse: + results: {sp::doc::ExprMotionVelocity::*} + code: + command /spdoc-exprmotionvelocity: + trigger: + set motion velocity of {_wind} to vector(1, 0, 0) + add vector(0, 0.5, 0) to motion velocity of modifiers of {_shape} + +parse: + results: {sp::doc::ExprScaleModifierRange::*} + code: + command /spdoc-exprscalemodifierrange: + trigger: + set scale start of {_taper} to 2 + set {_taper}'s scale end to 0.5 + +parse: + results: {sp::doc::ExprTwistAngle::*} + code: + command /spdoc-exprtwistangle: + trigger: + set twist angle of {_twist} to 720 + add 90 to {_twist}'s twist angle + +parse: + results: {sp::doc::ExprWaveProperty::*} + code: + command /spdoc-exprwaveproperty: + trigger: + set wave amplitude of {_wave} to 1.5 + add 1 to {_wave}'s wave frequency + set {_wave} to a wave with amplitude 0.5 and frequency 1 along the y axis: + set wave phase to 90 + +parse: + results: {sp::doc::ExprSecCustomModifier::*} + code: + command /spdoc-exprseccustommodifier: + trigger: + set {_shader} to a custom point modifier: + add 0.3 * sin(point's index * 20) to point's y + if mod(point's index, 4) = 0: + set point's visibility to false + add {_shader} to modifiers of {_shape} + +parse: + results: {sp::doc::ExprArc::*} + code: + command /spdoc-exprarc: + trigger: + set {_shape} to an arc with radius 10 and angle 45 degrees + set {_shape} to a circular sector of radius 3 and angle 90 degrees + set {_shape} to a sector of radius 3 and height 5 and angle 90 degrees + set {_shape} to a cylindrical sector of radius 1, height 0.5, and angle 45 + +parse: + results: {sp::doc::ExprBezierCurve::*} + code: + command /spdoc-exprbeziercurve: + trigger: + set {_shape} to a bezier curve from {a} to {b} with control points {c} and {d} + set {_shape} to a curve from player to player's target with control point (location 3 above player) + +parse: + results: {sp::doc::ExprCircle::*} + code: + command /spdoc-exprcircle: + trigger: + set {_shape} to circle with radius 10 + set {_shape} to a disc of radius 3 + set {_shape} to a solid cylinder with radius 3 and height 5 + +parse: + results: {sp::doc::ExprCuboid::*} + code: + command /spdoc-exprcuboid: + trigger: + set {_shape} to a solid cuboid with length 10, width 10, and height 10 + set {_shape} to a hollow cuboid from vector(-5, -5, -5) to vector(5, 5, 5) + draw the shape of a cuboid from player to player's target + +parse: + results: {sp::doc::ExprEllipse::*} + code: + command /spdoc-exprellipse: + trigger: + set {_shape} to oval with radii 10 and 3 + set {_shape} to a solid ellipse of radius 3 and 5 + set {_shape} to a hollow elliptical cylinder with radii 3 and 6 and height 5 + +parse: + results: {sp::doc::ExprEllipsoid::*} + code: + command /spdoc-exprellipsoid: + trigger: + set {_shape} to ellipsoid with radii 10, 3, and 8 + set {_shape} to a solid ellipsoid of radius 3 and 5 and 6 + set {_shape} to a hollow ellipsoid with radii 3, 6 and 5 + +parse: + results: {sp::doc::ExprEllipticalArc::*} + code: + command /spdoc-exprellipticalarc: + trigger: + set {_shape} to an elliptical arc with radii 10 and 3 and cutoff angle of 90 degrees + set {_shape} to an elliptical sector of radius 3 and 5 and cutoff angle of 45 degrees + set {_shape} to an elliptical cylindrical sector with radii 3 and 5, height 10, and cutoff angle of 3.1415 radians + +parse: + results: {sp::doc::ExprHeart::*} + code: + command /spdoc-exprheart: + trigger: + set {_heart} to heart with width 5 and length 4 + set {_heart} to heart shape with width 5, length 7, and eccentricity 2 + draw the shape of a heart of width 2 and length 2 at player + +parse: + results: {sp::doc::ExprHelix::*} + code: + command /spdoc-exprhelix: + trigger: + set {_shape} to helix with radius 10 and height 5 + set {_shape} to a spiral with radius 3 and height 5 and winding rate of 2 loops per meter + set {_shape} to a solid anti-clockwise helix with radius 3, height 5, winding rate 1 + +parse: + results: {sp::doc::ExprIrregularPolygon::*} + code: + command /spdoc-exprirregularpolygon: + trigger: + set {_shape} to a polygon with points vector(0, 0, 0), vector(1, 0, 0), and vector(1, 1, 1) + set {_shape} to a 2d polygon from points vector(0,0,1), vector(1,0,1) and vector(0,0,-1) and height 0.5 + +parse: + results: {sp::doc::ExprLine::*} + code: + command /spdoc-exprline: + trigger: + set {_shape} to line from vector(0, 0, 0) to vector(10, 10, 10) + set {_shape} to a line in direction vector(1, 1, 1) and length 10 + draw the shape of a line from vector(0, 0, 0) to vector(10, 10, 10) at player + # note that the following does not require a location to be drawn at + draw the shape of a line from player to player's target + draw the shape of a line from player to (all players in radius 10 of player) + draw the shape of a line connecting {_locations::*} + +parse: + results: {sp::doc::ExprRectangle::*} + code: + command /spdoc-exprrectangle: + trigger: + set {_shape} to rectangle with length 10 and width 5 + set {_shape} to a yz rectangle from vector(0, 0, 0) to vector(10, 10, 10) + draw the shape of a rectangle with length 10 and width 5 at player + # note that the following does not require a location to be drawn at + draw the shape of a rectangle from player to player's target + +parse: + results: {sp::doc::ExprRegularPolygon::*} + code: + command /spdoc-exprregularpolygon: + trigger: + set {_shape} to a regular polygon with 5 sides and radius 10 + set {_shape} to a solid regular polygon with 6 sides and side length 3 + draw the shape of a triangle with side length 5 at player + +parse: + results: {sp::doc::ExprRegularPolyhedron::*} + code: + command /spdoc-exprregularpolyhedron: + trigger: + set {_shape} to a tetrahedron with radius 1 + set {_shape} to a solid icosahedron with radius 2 + draw the shape of a tetrahedron with radius 5 at player + +parse: + results: {sp::doc::ExprSphere::*} + code: + command /spdoc-exprsphere: + trigger: + set {_shape} to sphere with radius 3 + set {_shape} to solid sphere with radius 10 + +parse: + results: {sp::doc::ExprSphericalCap::*} + code: + command /spdoc-exprsphericalcap: + trigger: + set {_shape} to spherical cap with radius 10 and angle 45 degrees + set {_shape} to a spherical sector of radius 3 and angle 90 degrees + +parse: + results: {sp::doc::ExprStar::*} + code: + command /spdoc-exprstar: + trigger: + set {_shape} to star with 5 points, inner radius 1, and outer radius 2 + draw the shape of a star with 4 points, inner radius 2, and outer radius 4 at player + +parse: + results: {sp::doc::EffRotateShape::*} + code: + command /spdoc-effrotateshape: + trigger: + rotate shape {_shape} around x axis by 90 degrees + rotate shape {_shape} around vector(0, 1, 0) by 1.4 radians + rotate shape {_shape} around local y axis by 90 + +parse: + results: {sp::doc::EffSetOrdering::*} + code: + command /spdoc-effsetordering: + trigger: + set the draw order of {_helix} to y axis + set the animation order of {_sphere} to reversed spherical distance + set the draw order of {_circle} to angle + set the drawing order of {_cube} to random + set the animation order of {_cube} to lowest-to-highest + set the draw order of {_shape} to default + +parse: + results: {sp::doc::EffToggleAxes::*} + code: + command /spdoc-efftoggleaxes: + trigger: + show local axes of {_shape} + hide global axes of {_shape} + hide local and global axes of {_shape} + show local axes of {_shape} and {_shape2} + +parse: + results: {sp::doc::ExprRotation::*} + code: + command /spdoc-exprrotation: + trigger: + set {_rotation} to rotation around vector(1, 0, 0) by an angle of 90 degrees + set {_rotation} to a rotation around vector(1, 1, 1) by 1.57 radians + set {_rotation} to a rotation from vector(1, 0, 0) to vector(0, 1, 0) + set {_rotation} to a rotation between vector(1, 0, 1) and vector(0, 1, 2) + +parse: + results: {sp::doc::ExprShapeCopy::*} + code: + command /spdoc-exprshapecopy: + trigger: + set {_shape-2} to a shape copy of {_shape} + set {_shapes::*} to shape copies of {_shapes::*} + +parse: + results: {sp::doc::ExprHelixWindingRate::*} + code: + command /spdoc-exprhelixwindingrate: + trigger: + set {_helix} to helix with radius 5, height 10, and winding rate 2 loops per block + set winding rate of {_helix} to 1 + set winding rate of {_helix} to 1/10 + set winding rate of {_helix} to 10.4 + +parse: + results: {sp::doc::ExprShapeBounds::*} + code: + command /spdoc-exprshapebounds: + trigger: + set {_low} to {_location} ~ shape minimum corner of {_shape} + set {_high} to {_location} ~ shape maximum corner of {_shape} + set {_size} to shape size of {_shape} + send "The shape is %x component of {_size}% by %y component of {_size}% by %z component of {_size}% blocks" + +parse: + results: {sp::doc::ExprShapeCutoffAngle::*} + code: + command /spdoc-exprshapecutoffangle: + trigger: + set {_shape}'s cutoff angle to 90 + set cutoff angle of {_shape} to 180 + reset {_shape}'s cutoff angle + +parse: + results: {sp::doc::ExprShapeLocations::*} + code: + command /spdoc-exprshapelocations: + trigger: + set {_locations::*} to point locations of (circle of radius 10) centered at player + teleport player to random element of {_locations::*} + # drawing the shape yourself + draw red dust particle at (particle locations of (circle of radius 10) centered at player) + +parse: + results: {sp::doc::ExprShapeLWH::*} + code: + command /spdoc-exprshapelwh: + trigger: + set {_shape}'s shape length to 5 + set {_shape}'s shape width to 5 + set {_shape}'s shape height to 5 + reset {_shape}'s shape length + reset {_shape}'s shape width + add 6 to {_shape}'s shape height + +parse: + results: {sp::doc::ExprShapeModifiers::*} + code: + command /spdoc-exprshapemodifiers: + trigger: + add scale from 1 to 0 to modifiers of {_shape} + add a twist of 90 degrees to modifiers of {_helix} + add gradient from red to blue along the y axis to modifiers of {_shape} + add motion modifier counterclockwise to modifiers of {_shape} + set modifiers of {_shape} to gradient from red to blue along the y axis + delete modifiers of {_shape} + +parse: + results: {sp::doc::ExprShapeNormal::*} + code: + command /spdoc-exprshapenormal: + trigger: + set {_shape}'s normal vector to vector(1, 0, 0) + set {_v} to {_shape}'s normal + reset {_shape}'s normal vector + +parse: + results: {sp::doc::ExprShapeOffset::*} + code: + command /spdoc-exprshapeoffset: + trigger: + set {_shape}'s offset vector to vector(1, 0, 0) + set offset of {_shape} to vector(0, 10, 0) + reset {_shape}'s offset vector + +parse: + results: {sp::doc::ExprShapeOrientation::*} + code: + command /spdoc-exprshapeorientation: + trigger: + set {_shape}'s orientation to quaternion(1, 0, 0, 0) + set {_shape}'s orientation to axisAngle(90, vector(0, 1, 0)) + set {_shape}'s orientation to {_shape2}'s orientation + reset {_shape}'s orientation + +parse: + results: {sp::doc::ExprShapeParticle::*} + code: + command /spdoc-exprshapeparticle: + trigger: + set {_shape}'s particle to flame particle + set {_shape}'s particle to red dust particle + set particle of {_shape} to 5 electric spark particles + reset {_shape}'s particle + +parse: + results: {sp::doc::ExprShapeParticleDensity::*} + code: + command /spdoc-exprshapeparticledensity: + trigger: + set {_shape}'s particle density to 10 + set {_shape}'s point count to 100 + reset {_shape}'s particle density + reset {_shape}'s point count + +parse: + results: {sp::doc::ExprShapePointLimit::*} + code: + command /spdoc-exprshapepointlimit: + trigger: + set the shape point limit of {_sphere} to 500 + add 1000 to {_shape}'s shape point limit + reset the shape point limit of {_shape} + +parse: + results: {sp::doc::ExprShapePoints::*} + code: + command /spdoc-exprshapepoints: + trigger: + set {_vectors::*} to points of (circle of radius 10) + teleport player to (player ~ random element of {_vectors::*}) + set {_randomVectorInEllipsoid} to random element of points of (solid ellipsoid with radii 10, 5 and 2) + +parse: + results: {sp::doc::ExprShapeRadius::*} + code: + command /spdoc-exprshaperadius: + trigger: + set {_shape}'s radius to 5 + set radius of {_shape} to 10 + reset {_shape}'s radius + +parse: + results: {sp::doc::ExprShapeReach::*} + code: + command /spdoc-exprshapereach: + trigger: + set {_reach} to shape reach of {_shape} + loop all players in radius {_reach} + 32 of {_location}: + send "Look up!" to loop-player + +parse: + results: {sp::doc::ExprShapeRelativeAxis::*} + code: + command /spdoc-exprshaperelativeaxis: + trigger: + set {_vX} to {_shape}'s relative x axis + set {_vY} to {_shape}'s relative y axis + set {_vZ} to {_shape}'s relative z axis + rotate shape {_shape} around {_vY} by 90 degrees + set {_vY-offset} to {_shape}'s relative y axis + {_shape}'s relative z axis + rotate shape {_shape} around {_vY-offset} by 60 degrees + +parse: + results: {sp::doc::ExprShapeSideLength::*} + code: + command /spdoc-exprshapesidelength: + trigger: + set side length of {_shape} to 5 + set {_shape}'s side length to 6 + send side length of {_shape} + +parse: + results: {sp::doc::ExprShapeSides::*} + code: + command /spdoc-exprshapesides: + trigger: + set sides of {_shape} to 5 + set {_shape}'s side count to 6 + send sides of {_shape} + +parse: + results: {sp::doc::ExprStarPoints::*} + code: + command /spdoc-exprstarpoints: + trigger: + set {_shape}'s star points to 5 + set star points of {_shape} to 10 + set star points of {_shape} to 2 * (star points of {_shape}) + +parse: + results: {sp::doc::ExprStarRadii::*} + code: + command /spdoc-exprstarradii: + trigger: + set {_star} to a star with 5 points, inner radius 1, and outer radius 2 + set {_star}'s inner radius to 2 + set {_star}'s outer radius to 3 + +test "docs of ExprDrawnShapes parse": + assert {sp::doc::ExprDrawnShapes::*} is not set with "%{sp::doc::ExprDrawnShapes::*}%" + delete {sp::doc::ExprDrawnShapes::*} + +test "docs of EffSecDrawShape parse": + assert {sp::doc::EffSecDrawShape::*} is not set with "%{sp::doc::EffSecDrawShape::*}%" + delete {sp::doc::EffSecDrawShape::*} + +test "docs of EffSecDrawShapeAnimation parse": + assert {sp::doc::EffSecDrawShapeAnimation::*} is not set with "%{sp::doc::EffSecDrawShapeAnimation::*}%" + delete {sp::doc::EffSecDrawShapeAnimation::*} + +test "docs of ExprGradientModifier parse": + assert {sp::doc::ExprGradientModifier::*} is not set with "%{sp::doc::ExprGradientModifier::*}%" + delete {sp::doc::ExprGradientModifier::*} + +test "docs of ExprMotionModifier parse": + assert {sp::doc::ExprMotionModifier::*} is not set with "%{sp::doc::ExprMotionModifier::*}%" + delete {sp::doc::ExprMotionModifier::*} + +test "docs of ExprScaleModifier parse": + assert {sp::doc::ExprScaleModifier::*} is not set with "%{sp::doc::ExprScaleModifier::*}%" + delete {sp::doc::ExprScaleModifier::*} + +test "docs of ExprTwistModifier parse": + assert {sp::doc::ExprTwistModifier::*} is not set with "%{sp::doc::ExprTwistModifier::*}%" + delete {sp::doc::ExprTwistModifier::*} + +test "docs of ExprWaveModifier parse": + assert {sp::doc::ExprWaveModifier::*} is not set with "%{sp::doc::ExprWaveModifier::*}%" + delete {sp::doc::ExprWaveModifier::*} + +test "docs of EffAddColorStop parse": + assert {sp::doc::EffAddColorStop::*} is not set with "%{sp::doc::EffAddColorStop::*}%" + delete {sp::doc::EffAddColorStop::*} + +test "docs of ExprEasing-functions parse": + assert {sp::doc::ExprEasing-functions::*} is not set with "%{sp::doc::ExprEasing-functions::*}%" + delete {sp::doc::ExprEasing-functions::*} + +test "docs of ExprEasing parse": + assert {sp::doc::ExprEasing::*} is not set with "%{sp::doc::ExprEasing::*}%" + delete {sp::doc::ExprEasing::*} + +test "docs of ExprNewModifier parse": + assert {sp::doc::ExprNewModifier::*} is not set with "%{sp::doc::ExprNewModifier::*}%" + delete {sp::doc::ExprNewModifier::*} + +test "docs of ExprPointCoordinate parse": + assert {sp::doc::ExprPointCoordinate::*} is not set with "%{sp::doc::ExprPointCoordinate::*}%" + delete {sp::doc::ExprPointCoordinate::*} + +test "docs of ExprPointCount parse": + assert {sp::doc::ExprPointCount::*} is not set with "%{sp::doc::ExprPointCount::*}%" + delete {sp::doc::ExprPointCount::*} + +test "docs of ExprPointDisplacement parse": + assert {sp::doc::ExprPointDisplacement::*} is not set with "%{sp::doc::ExprPointDisplacement::*}%" + delete {sp::doc::ExprPointDisplacement::*} + +test "docs of ExprPointIndex parse": + assert {sp::doc::ExprPointIndex::*} is not set with "%{sp::doc::ExprPointIndex::*}%" + delete {sp::doc::ExprPointIndex::*} + +test "docs of ExprPointMotion parse": + assert {sp::doc::ExprPointMotion::*} is not set with "%{sp::doc::ExprPointMotion::*}%" + delete {sp::doc::ExprPointMotion::*} + +test "docs of ExprPointScale parse": + assert {sp::doc::ExprPointScale::*} is not set with "%{sp::doc::ExprPointScale::*}%" + delete {sp::doc::ExprPointScale::*} + +test "docs of ExprPointVisible parse": + assert {sp::doc::ExprPointVisible::*} is not set with "%{sp::doc::ExprPointVisible::*}%" + delete {sp::doc::ExprPointVisible::*} + +test "docs of ExprGradientColors parse": + assert {sp::doc::ExprGradientColors::*} is not set with "%{sp::doc::ExprGradientColors::*}%" + delete {sp::doc::ExprGradientColors::*} + +test "docs of ExprModifierEasing parse": + assert {sp::doc::ExprModifierEasing::*} is not set with "%{sp::doc::ExprModifierEasing::*}%" + delete {sp::doc::ExprModifierEasing::*} + +test "docs of ExprMotionVelocity parse": + assert {sp::doc::ExprMotionVelocity::*} is not set with "%{sp::doc::ExprMotionVelocity::*}%" + delete {sp::doc::ExprMotionVelocity::*} + +test "docs of ExprScaleModifierRange parse": + assert {sp::doc::ExprScaleModifierRange::*} is not set with "%{sp::doc::ExprScaleModifierRange::*}%" + delete {sp::doc::ExprScaleModifierRange::*} + +test "docs of ExprTwistAngle parse": + assert {sp::doc::ExprTwistAngle::*} is not set with "%{sp::doc::ExprTwistAngle::*}%" + delete {sp::doc::ExprTwistAngle::*} + +test "docs of ExprWaveProperty parse": + assert {sp::doc::ExprWaveProperty::*} is not set with "%{sp::doc::ExprWaveProperty::*}%" + delete {sp::doc::ExprWaveProperty::*} + +test "docs of ExprSecCustomModifier parse": + assert {sp::doc::ExprSecCustomModifier::*} is not set with "%{sp::doc::ExprSecCustomModifier::*}%" + delete {sp::doc::ExprSecCustomModifier::*} + +test "docs of ExprArc parse": + assert {sp::doc::ExprArc::*} is not set with "%{sp::doc::ExprArc::*}%" + delete {sp::doc::ExprArc::*} + +test "docs of ExprBezierCurve parse": + assert {sp::doc::ExprBezierCurve::*} is not set with "%{sp::doc::ExprBezierCurve::*}%" + delete {sp::doc::ExprBezierCurve::*} + +test "docs of ExprCircle parse": + assert {sp::doc::ExprCircle::*} is not set with "%{sp::doc::ExprCircle::*}%" + delete {sp::doc::ExprCircle::*} + +test "docs of ExprCuboid parse": + assert {sp::doc::ExprCuboid::*} is not set with "%{sp::doc::ExprCuboid::*}%" + delete {sp::doc::ExprCuboid::*} + +test "docs of ExprEllipse parse": + assert {sp::doc::ExprEllipse::*} is not set with "%{sp::doc::ExprEllipse::*}%" + delete {sp::doc::ExprEllipse::*} + +test "docs of ExprEllipsoid parse": + assert {sp::doc::ExprEllipsoid::*} is not set with "%{sp::doc::ExprEllipsoid::*}%" + delete {sp::doc::ExprEllipsoid::*} + +test "docs of ExprEllipticalArc parse": + assert {sp::doc::ExprEllipticalArc::*} is not set with "%{sp::doc::ExprEllipticalArc::*}%" + delete {sp::doc::ExprEllipticalArc::*} + +test "docs of ExprHeart parse": + assert {sp::doc::ExprHeart::*} is not set with "%{sp::doc::ExprHeart::*}%" + delete {sp::doc::ExprHeart::*} + +test "docs of ExprHelix parse": + assert {sp::doc::ExprHelix::*} is not set with "%{sp::doc::ExprHelix::*}%" + delete {sp::doc::ExprHelix::*} + +test "docs of ExprIrregularPolygon parse": + assert {sp::doc::ExprIrregularPolygon::*} is not set with "%{sp::doc::ExprIrregularPolygon::*}%" + delete {sp::doc::ExprIrregularPolygon::*} + +test "docs of ExprLine parse": + assert {sp::doc::ExprLine::*} is not set with "%{sp::doc::ExprLine::*}%" + delete {sp::doc::ExprLine::*} + +test "docs of ExprRectangle parse": + assert {sp::doc::ExprRectangle::*} is not set with "%{sp::doc::ExprRectangle::*}%" + delete {sp::doc::ExprRectangle::*} + +test "docs of ExprRegularPolygon parse": + assert {sp::doc::ExprRegularPolygon::*} is not set with "%{sp::doc::ExprRegularPolygon::*}%" + delete {sp::doc::ExprRegularPolygon::*} + +test "docs of ExprRegularPolyhedron parse": + assert {sp::doc::ExprRegularPolyhedron::*} is not set with "%{sp::doc::ExprRegularPolyhedron::*}%" + delete {sp::doc::ExprRegularPolyhedron::*} + +test "docs of ExprSphere parse": + assert {sp::doc::ExprSphere::*} is not set with "%{sp::doc::ExprSphere::*}%" + delete {sp::doc::ExprSphere::*} + +test "docs of ExprSphericalCap parse": + assert {sp::doc::ExprSphericalCap::*} is not set with "%{sp::doc::ExprSphericalCap::*}%" + delete {sp::doc::ExprSphericalCap::*} + +test "docs of ExprStar parse": + assert {sp::doc::ExprStar::*} is not set with "%{sp::doc::ExprStar::*}%" + delete {sp::doc::ExprStar::*} + +test "docs of EffRotateShape parse": + assert {sp::doc::EffRotateShape::*} is not set with "%{sp::doc::EffRotateShape::*}%" + delete {sp::doc::EffRotateShape::*} + +test "docs of EffSetOrdering parse": + assert {sp::doc::EffSetOrdering::*} is not set with "%{sp::doc::EffSetOrdering::*}%" + delete {sp::doc::EffSetOrdering::*} + +test "docs of EffToggleAxes parse": + assert {sp::doc::EffToggleAxes::*} is not set with "%{sp::doc::EffToggleAxes::*}%" + delete {sp::doc::EffToggleAxes::*} + +test "docs of ExprRotation parse": + assert {sp::doc::ExprRotation::*} is not set with "%{sp::doc::ExprRotation::*}%" + delete {sp::doc::ExprRotation::*} + +test "docs of ExprShapeCopy parse": + assert {sp::doc::ExprShapeCopy::*} is not set with "%{sp::doc::ExprShapeCopy::*}%" + delete {sp::doc::ExprShapeCopy::*} + +test "docs of ExprHelixWindingRate parse": + assert {sp::doc::ExprHelixWindingRate::*} is not set with "%{sp::doc::ExprHelixWindingRate::*}%" + delete {sp::doc::ExprHelixWindingRate::*} + +test "docs of ExprShapeBounds parse": + assert {sp::doc::ExprShapeBounds::*} is not set with "%{sp::doc::ExprShapeBounds::*}%" + delete {sp::doc::ExprShapeBounds::*} + +test "docs of ExprShapeCutoffAngle parse": + assert {sp::doc::ExprShapeCutoffAngle::*} is not set with "%{sp::doc::ExprShapeCutoffAngle::*}%" + delete {sp::doc::ExprShapeCutoffAngle::*} + +test "docs of ExprShapeLocations parse": + assert {sp::doc::ExprShapeLocations::*} is not set with "%{sp::doc::ExprShapeLocations::*}%" + delete {sp::doc::ExprShapeLocations::*} + +test "docs of ExprShapeLWH parse": + assert {sp::doc::ExprShapeLWH::*} is not set with "%{sp::doc::ExprShapeLWH::*}%" + delete {sp::doc::ExprShapeLWH::*} + +test "docs of ExprShapeModifiers parse": + assert {sp::doc::ExprShapeModifiers::*} is not set with "%{sp::doc::ExprShapeModifiers::*}%" + delete {sp::doc::ExprShapeModifiers::*} + +test "docs of ExprShapeNormal parse": + assert {sp::doc::ExprShapeNormal::*} is not set with "%{sp::doc::ExprShapeNormal::*}%" + delete {sp::doc::ExprShapeNormal::*} + +test "docs of ExprShapeOffset parse": + assert {sp::doc::ExprShapeOffset::*} is not set with "%{sp::doc::ExprShapeOffset::*}%" + delete {sp::doc::ExprShapeOffset::*} + +test "docs of ExprShapeOrientation parse": + assert {sp::doc::ExprShapeOrientation::*} is not set with "%{sp::doc::ExprShapeOrientation::*}%" + delete {sp::doc::ExprShapeOrientation::*} + +test "docs of ExprShapeParticle parse": + assert {sp::doc::ExprShapeParticle::*} is not set with "%{sp::doc::ExprShapeParticle::*}%" + delete {sp::doc::ExprShapeParticle::*} + +test "docs of ExprShapeParticleDensity parse": + assert {sp::doc::ExprShapeParticleDensity::*} is not set with "%{sp::doc::ExprShapeParticleDensity::*}%" + delete {sp::doc::ExprShapeParticleDensity::*} + +test "docs of ExprShapePointLimit parse": + assert {sp::doc::ExprShapePointLimit::*} is not set with "%{sp::doc::ExprShapePointLimit::*}%" + delete {sp::doc::ExprShapePointLimit::*} + +test "docs of ExprShapePoints parse": + assert {sp::doc::ExprShapePoints::*} is not set with "%{sp::doc::ExprShapePoints::*}%" + delete {sp::doc::ExprShapePoints::*} + +test "docs of ExprShapeRadius parse": + assert {sp::doc::ExprShapeRadius::*} is not set with "%{sp::doc::ExprShapeRadius::*}%" + delete {sp::doc::ExprShapeRadius::*} + +test "docs of ExprShapeReach parse": + assert {sp::doc::ExprShapeReach::*} is not set with "%{sp::doc::ExprShapeReach::*}%" + delete {sp::doc::ExprShapeReach::*} + +test "docs of ExprShapeRelativeAxis parse": + assert {sp::doc::ExprShapeRelativeAxis::*} is not set with "%{sp::doc::ExprShapeRelativeAxis::*}%" + delete {sp::doc::ExprShapeRelativeAxis::*} + +test "docs of ExprShapeSideLength parse": + assert {sp::doc::ExprShapeSideLength::*} is not set with "%{sp::doc::ExprShapeSideLength::*}%" + delete {sp::doc::ExprShapeSideLength::*} + +test "docs of ExprShapeSides parse": + assert {sp::doc::ExprShapeSides::*} is not set with "%{sp::doc::ExprShapeSides::*}%" + delete {sp::doc::ExprShapeSides::*} + +test "docs of ExprStarPoints parse": + assert {sp::doc::ExprStarPoints::*} is not set with "%{sp::doc::ExprStarPoints::*}%" + delete {sp::doc::ExprStarPoints::*} + +test "docs of ExprStarRadii parse": + assert {sp::doc::ExprStarRadii::*} is not set with "%{sp::doc::ExprStarRadii::*}%" + delete {sp::doc::ExprStarRadii::*} + diff --git a/skript-particle/src/test/scripts/modifiers.sk b/skript-particle/src/test/scripts/modifiers.sk new file mode 100644 index 0000000..5a3fa84 --- /dev/null +++ b/skript-particle/src/test/scripts/modifiers.sk @@ -0,0 +1,161 @@ +# Parsing and list-behavior tests for point modifiers, gradients, and easings. + +test "scale modifier constructor": + set {_m} to a scale from 1 to 0 along the y axis + assert {_m} is set with "scale modifier constructor failed" + set {_m} to a scale from 1 to 2 along the spherical distance scaling the xyz axes + assert {_m} is set with "scale modifier with axes failed" + +test "twist modifier constructor": + set {_m} to a twist of 90 degrees along the y axis in the xz plane + assert {_m} is set with "twist modifier constructor failed" + assert twist angle of {_m} is 90 with "the twist angle should be in degrees" + set {_m} to a twist of 3.14159 radians + assert twist angle of {_m} is between 179.9 and 180.1 with "a twist in radians should be converted" + set twist angle of {_m} to 1.5708 radians + assert twist angle of {_m} is between 89.9 and 90.1 with "setting the twist angle in radians should be converted" + +test "wave modifier constructor": + set {_m} to a wave with amplitude 0.5 and frequency 2 + assert {_m} is set with "wave modifier constructor failed" + set {_m} to a wave with amplitude 0.5 and frequency 2 along the draw order displacing the x axis + assert {_m} is set with "wave modifier with input and axis failed" + +test "gradient modifier constructors": + set {_g} to a gradient from red to blue along the y axis + assert {_g} is set with "two colour gradient constructor failed" + set {_g} to a colour gradient of red, yellow and blue along the angle + assert {_g} is set with "colour list gradient constructor failed" + set {_g} to a radial gradient of white and black + assert {_g} is set with "radial gradient constructor failed" + set {_g} to an angular gradient from red to blue + assert {_g} is set with "angular gradient constructor failed" + set {_g} to a spherical gradient from yellow to orange + assert {_g} is set with "spherical gradient constructor failed" + +test "motion modifier velocity": + set {_m} to a motion modifier with relative velocity vector(-1, 0.5, 2) + assert motion velocity of {_m} is vector(-1, 0.5, 2) with "a relative motion modifier should keep its velocity" + set {_m} to a motion modifier with velocity vector(0, 1, 0): + add vector(1, 0, 0) to motion velocity + assert motion velocity of {_m} is vector(1, 1, 0) with "changing the velocity in the section failed" + set {_m} to a motion modifier outwards + assert motion velocity of {_m} is not set with "a named motion has no velocity" + +test "easing constructors": + set {_e} to linear easing + assert {_e} is set with "linear easing constructor failed" + set {_e} to ease in quadratic + assert {_e} is set with "quadratic ease in constructor failed" + set {_e} to a power ease in with exponent 3 + assert {_e} is set with "power easing constructor failed" + +test "modifier constructor sections": + set {_m} to a wave with amplitude 0.5 and frequency 2: + set wave amplitude of the new modifier to 1.5 + set wave phase to 90 + assert wave amplitude of {_m} is 1.5 with "'the new modifier' should be the modifier being created" + assert wave phase of {_m} is 90 with "modifier properties should default to the new modifier in its section" + + set {_m} to a scale from 1 to 0 along the y axis: + set modifier easing to ease in-out sine + assert modifier easing of {_m} is set with "setting the easing in the section failed" + + set {_outer} to a twist of 90 degrees: + set {_inner} to a wave with amplitude 1 and frequency 1: + set wave frequency to 3 + add 90 to twist angle + assert twist angle of {_outer} is 180 with "a nested section should not change which modifier the outer section uses" + assert wave frequency of {_inner} is 3 with "the nested section should change its own modifier" + +parse: + results: {sp::parse::newmodifier::*} + code: + on load: + set {_x} to the new modifier + +test "new modifier errors outside a section": + set {_found} to false + loop {sp::parse::newmodifier::*}: + if loop-value contains "modifier constructor": + set {_found} to true + assert {_found} is true with "expected a parse error using 'the new modifier' outside a section, got: %{sp::parse::newmodifier::*}%" + delete {sp::parse::newmodifier::*} + +parse: + results: {sp::parse::modifierdelay::*} + code: + on load: + set {_m} to a twist of 90 degrees: + wait 1 tick + +test "delays are rejected inside a modifier section": + set {_found} to false + loop {sp::parse::modifierdelay::*}: + if loop-value contains "Delays": + set {_found} to true + assert {_found} is true with "expected a parse error for a delay in a modifier section, got: %{sp::parse::modifierdelay::*}%" + delete {sp::parse::modifierdelay::*} + +test "wave modifier properties": + set {_w} to a wave with amplitude 0.5 and frequency 2 + assert wave amplitude of {_w} is 0.5 with "wave amplitude should match the constructor" + assert wave frequency of {_w} is 2 with "wave frequency should match the constructor" + assert wave phase of {_w} is 0 with "wave phase should start at 0" + add 1 to wave frequency of {_w} + assert wave frequency of {_w} is 3 with "adding to the wave frequency failed" + set wave phase of {_w} to 45 + reset wave phase of {_w} + assert wave phase of {_w} is 0 with "resetting the wave phase failed" + set {_t} to a twist of 90 degrees + assert wave amplitude of {_t} is not set with "a twist has no wave amplitude" + +test "scale modifier properties": + set {_s} to a scale from 1 to 0 + assert scale start of {_s} is 1 with "scale start should match the constructor" + assert scale end of {_s} is 0 with "scale end should match the constructor" + set scale end of {_s} to 0.5 + assert {_s}'s scale end is 0.5 with "setting the scale end failed" + delete scale start of {_s} + assert scale start of {_s} is 1 with "deleting the scale start should set it to 1" + +test "modifier easing": + set {_s} to a scale from 1 to 0 + set modifier easing of {_s} to ease in cubic + assert modifier easing of {_s} is set with "setting the modifier easing failed" + set {_g} to a gradient from red to blue + set modifier easing of {_g} to ease out cubic + assert modifier easing of {_g} is set with "gradients should have an easing" + set {_w} to a wave with amplitude 1 and frequency 1 + assert modifier easing of {_w} is not set with "waves have no easing" + +test "gradient colours": + set {_g} to a gradient of red, white and blue + assert size of gradient colours of {_g} is 3 with "a gradient of three colours should have three colours" + add black to gradient colours of {_g} + assert size of gradient colours of {_g} is 4 with "adding a gradient colour failed" + set gradient colors of {_g} to red and blue + assert size of gradient colours of {_g} is 2 with "setting the gradient colours failed" + +test "shape modifier list behavior": + set {_c} to a circle with radius 3 + assert modifiers of {_c} is not set with "a new shape should have no modifiers" + + add a scale from 1 to 0 along the y axis to modifiers of {_c} + assert size of modifiers of {_c} is 1 with "adding a modifier should result in 1 modifier" + + add a gradient from red to blue along the y axis to modifiers of {_c} + assert size of modifiers of {_c} is 2 with "adding a second modifier should result in 2 modifiers" + + set modifiers of {_c} to a wave with amplitude 1 and frequency 2 + assert size of modifiers of {_c} is 1 with "setting modifiers should replace the list" + + delete modifiers of {_c} + assert modifiers of {_c} is not set with "deleting modifiers should clear the list" + +test "geometry modifier affects sampled points": + set {_c} to a circle with radius 3 + add a scale from 0 to 0 along the y axis to modifiers of {_c} + loop points of {_c}: + assert x of loop-value is between -0.001 and 0.001 with "scale-to-zero modifier should collapse x coordinates" + assert z of loop-value is between -0.001 and 0.001 with "scale-to-zero modifier should collapse z coordinates" diff --git a/skript-particle/src/test/scripts/properties.sk b/skript-particle/src/test/scripts/properties.sk new file mode 100644 index 0000000..61982e3 --- /dev/null +++ b/skript-particle/src/test/scripts/properties.sk @@ -0,0 +1,88 @@ +# Tests for shape property expressions: particle density and count, cutoff angle, particles, and modifier lists. + +test "default particle density is not driven by the point cap": + set {_c} to a circle with radius 1 + assert size of points of {_c} is less than 100 with "a small circle should use the default density, not fill up to the point cap" + assert particle density of {_c} is 4 with "the default particle density should be 4 per block" + +test "point count matches the sampled points": + set {_c} to a circle with radius 3 + assert point count of {_c} is size of points of {_c} with "point count should be the number of sampled points" + set point count of {_c} to 100 + assert point count of {_c} is size of points of {_c} with "point count should still match after being set" + assert point count of {_c} is between 90 and 110 with "setting the point count should land near the target" + add 50 to point count of {_c} + assert point count of {_c} is between 140 and 160 with "adding to the point count should add to the actual count" + +test "point count on a filled flat shape": + set {_s} to a sector with radius 2 and cutoff angle 90 + set point count of {_s} to 200 + assert point count of {_s} is between 150 and 250 with "a filled flat shape should accept a point count" + +test "resetting particle density": + set {_c} to a circle with radius 3 + set particle density of {_c} to 20 + assert particle density of {_c} is 20 with "setting the particle density failed" + reset particle density of {_c} + assert particle density of {_c} is 4 with "resetting should return to the default particle density" + +test "cutoff angle is in degrees both ways": + set {_a} to an arc with radius 2 and cutoff angle 90 + assert cutoff angle of {_a} is 90 with "cutoff angle should be read back in degrees" + set cutoff angle of {_a} to 45 + assert cutoff angle of {_a} is 45 with "cutoff angle should round-trip in degrees" + add 15 to cutoff angle of {_a} + assert cutoff angle of {_a} is 60 with "adding to the cutoff angle should work in degrees" + +test "heart width and length": + set {_h} to a heart with width 2 and length 5 + assert shape width of {_h} is 2 with "heart width should be the given width" + assert shape length of {_h} is 5 with "heart length should be the given length" + +test "side counts survive conversion": + set {_p} to a regular polygon with 25 sides and radius 3 + assert sides of {_p} is 25 with "a 25-sided polygon should report 25 sides" + set {_s} to a star with 25 points, inner radius 1 and outer radius 2 + assert star points of {_s} is 25 with "a 25-pointed star should report 25 points" + +test "modifiers can be removed again": + set {_c} to a circle with radius 3 + set {_twist} to a twist of 90 degrees + set {_wave} to a wave with amplitude 1 and frequency 2 + + add {_twist} to modifiers of {_c} + add {_wave} to modifiers of {_c} + remove {_twist} from modifiers of {_c} + assert size of modifiers of {_c} is 1 with "removing an added modifier should remove it" + + set modifiers of {_c} to {_twist} and {_wave} + remove {_wave} from modifiers of {_c} + assert size of modifiers of {_c} is 1 with "removing a modifier after setting the list should remove it" + +test "shapes do not share added modifiers": + set {_a} to a circle with radius 3 + set {_b} to a circle with radius 3 + set {_twist} to a twist of 90 degrees + add {_twist} to modifiers of {_a} + add {_twist} to modifiers of {_b} + remove {_twist} from modifiers of {_a} + assert size of modifiers of {_a} is 0 with "the modifier should be removed from the first shape" + assert size of modifiers of {_b} is 1 with "the second shape should keep its own copy of the modifier" + +test "shapes from two locations are anchored at the first": + set {_a} to location(10, 64, 10, world "world") + set {_b} to location(10, 70, 10, world "world") + set {_l} to a line from {_a} to {_b} + assert {_l} is set with "line between two locations did not produce a shape" + loop points of {_l}: + assert x of loop-value is between -0.001 and 0.001 with "line points should be relative to the line's start" + assert y of loop-value is between -0.001 and 6.001 with "line points should span from the start to the end" + +test "animation ordering sorts the points without dropping any": + set {_c} to a solid cuboid with length 2, width 2 and height 2 + set {_count} to size of points of {_c} + set the animation order of {_c} to lowest-to-highest + assert size of points of {_c} is {_count} with "ordering the points should not drop any of them" + set {_points::*} to points of {_c} + set {_first} to {_points::1} + assert (x of {_first} + y of {_first} + z of {_first}) is -3 with "lowest-to-highest should start at the lowest corner" diff --git a/skript-particle/src/test/scripts/sampling.sk b/skript-particle/src/test/scripts/sampling.sk new file mode 100644 index 0000000..2bc7ede --- /dev/null +++ b/skript-particle/src/test/scripts/sampling.sk @@ -0,0 +1,49 @@ +# Tests for sampling syntax: point limit, draw order, bounds and reach. + +test "shape point limit": + set {_s} to a sphere with radius 10 + assert shape point limit of {_s} is 10000 with "the default point limit should be 10,000" + set shape point limit of {_s} to 200 + assert shape point limit of {_s} is 200 with "setting the point limit failed" + assert size of points of {_s} is less than or equal to 200 with "a shape should never have more points than its limit" + add 100 to {_s}'s shape point limit + assert shape point limit of {_s} is 300 with "adding to the point limit failed" + delete shape point limit of {_s} + assert shape point limit of {_s} is 10000 with "deleting the point limit should restore the default" + set particle density of {_s} to 2 + assert shape point limit of {_s} is 100000 with "an explicit density should raise the default limit" + +test "shape draw order along an input": + set {_l} to a line from vector(0, 0, 0) to vector(0, 5, 0) + set the draw order of {_l} to reversed y axis + set {_points::*} to points of {_l} + assert y component of {_points::1} is greater than y component of {_points::%size of {_points::*}%} with "reversed y axis should start at the top" + set the animation order of {_l} to y axis + set {_points::*} to points of {_l} + assert y component of {_points::1} is less than y component of {_points::%size of {_points::*}%} with "y axis should start at the bottom" + +test "shape draw order keywords parse and keep every point": + set {_s} to a sphere with radius 2 + set {_count} to size of points of {_s} + set the drawing order of {_s} to random + assert size of points of {_s} is {_count} with "random order changed the number of points" + set the draw order of {_s} to angle + set the draw order of {_s} to spherical distance + set the draw order of {_s} to radius + set the draw order of {_s} to lowest-to-highest + set the draw order of {_s} to highest-to-lowest + set the draw order of {_s} to default + assert size of points of {_s} is {_count} with "resetting the order changed the number of points" + +test "shape bounds and reach": + set {_c} to a circle with radius 2 + set {_min} to shape minimum corner of {_c} + set {_max} to shape max corner of {_c} + assert x component of {_min} is between -2.01 and -1.99 with "the minimum corner of a circle should be at -radius (got %{_min}%)" + assert x component of {_max} is between 1.99 and 2.01 with "the maximum corner of a circle should be at +radius (got %{_max}%)" + set {_size} to shape size of {_c} + assert x component of {_size} is between 3.99 and 4.01 with "the size of a circle should be its diameter (got %{_size}%)" + assert y component of {_size} is 0 with "a circle should be flat" + assert shape reach of {_c} is between 1.99 and 2.01 with "the reach of a circle should be its radius" + set offset of {_c} to vector(0, 3, 0) + assert y component of shape minimum corner of {_c} is between 2.99 and 3.01 with "the bounds should include the offset" diff --git a/skript-particle/src/test/scripts/shapes.sk b/skript-particle/src/test/scripts/shapes.sk new file mode 100644 index 0000000..3c94954 --- /dev/null +++ b/skript-particle/src/test/scripts/shapes.sk @@ -0,0 +1,70 @@ +# Basic parsing and behavior tests for shape constructors and properties. + +test "circle constructor and radius property": + set {_c} to a circle with radius 3 + assert {_c} is set with "circle constructor did not produce a shape" + assert radius of {_c} is 3 with "radius of new circle should be 3" + set radius of {_c} to 5 + assert radius of {_c} is 5 with "setting the radius of a circle failed" + +test "shape constructor section runs its body": + set {_c} to a circle with radius 2: + set radius of the current shape to 4 + assert {_c} is set with "circle constructor with section did not produce a shape" + assert radius of {_c} is 4 with "constructor section body did not modify the shape" + +test "line constructor": + set {_l} to a line from vector(0, 0, 0) to vector(0, 5, 0) + assert {_l} is set with "line constructor did not produce a shape" + +test "cylinder constructor and lwh properties": + set {_cyl} to a cylinder with radius 2 and height 4 + assert {_cyl} is set with "cylinder constructor did not produce a shape" + assert radius of {_cyl} is 2 with "radius of new cylinder should be 2" + assert shape height of {_cyl} is 4 with "height of new cylinder should be 4" + +test "shape scale property": + set {_c} to a circle with radius 1 + assert scale of {_c} is 1 with "default shape scale should be 1" + set scale of {_c} to 2 + assert scale of {_c} is 2 with "setting shape scale failed" + add 0.5 to {_c}'s scale + assert scale of {_c} is 2.5 with "adding to shape scale failed" + +test "shape style property": + # the shape is in a variable, so this must not be taken for a boss bar style + set {_s} to a sphere with radius 2 + set style of {_s} to solid + set {_solid} to size of points of {_s} + set {_s}'s style to wireframe + set {_wireframe} to size of points of {_s} + assert {_solid} is greater than {_wireframe} with "changing the style of a shape in a variable had no effect (solid %{_solid}%, wireframe %{_wireframe}%)" + +test "shape scale reset": + set {_c} to a circle with radius 1 + set scale of {_c} to 3 + delete scale of {_c} + assert scale of {_c} is 1 with "deleting shape scale failed" + # 'reset' does not reach shapes on Skript 2.17.1: PropertyBaseExpression#acceptChange returns early for + # DELETE/RESET once any other type with a scale accepts the change, so the shape's change types are never stored + +test "shape particle accepts skript particles": + set {_c} to a circle with radius 1 + set particle of {_c} to red dust particle + assert particle of {_c} is set with "setting a shape's particle to red dust failed" + set {_d} to a sphere with radius 1 + set {_d}'s particle to particle of {_c} + assert particle of {_d} is set with "copying a particle between shapes failed" + reset particle of {_c} + assert particle of {_c} is set with "resetting a shape's particle failed" + +test "shape points are sampled": + set {_c} to a circle with radius 3 + assert size of points of {_c} is greater than 0 with "a circle should sample at least one point" + +test "skript quaternions work as shape orientations": + set {_q} to axisAngle(180, vector(0, 1, 0)) + assert {_q} is set with "axisAngle did not return a quaternion" + set {_c} to a circle with radius 1 + set orientation of {_c} to {_q} + assert orientation of {_c} is set with "quaternion from axisAngle could not be used as a shape orientation"