Преглед на файлове

Merge branch 'jMonkeyEngine:master' into capdevon-BinaryExporter

Wyatt Gillette преди 1 месец
родител
ревизия
f8a7a9d741
променени са 47 файла, в които са добавени 2637 реда и са изтрити 895 реда
  1. 68 0
      .github/actions/tools/uploadToCentral.sh
  2. 56 58
      .github/workflows/main.yml
  3. 1 1
      .github/workflows/screenshot-test-comment.yml
  4. 20 12
      common.gradle
  5. 148 83
      jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java
  6. 54 7
      jme3-core/src/main/java/com/jme3/anim/MatrixJointModelTransform.java
  7. 181 150
      jme3-core/src/main/java/com/jme3/anim/SkinningControl.java
  8. 92 39
      jme3-core/src/main/java/com/jme3/effect/shapes/EmitterMeshFaceShape.java
  9. 167 0
      jme3-core/src/main/java/com/jme3/environment/util/Circle.java
  10. 359 86
      jme3-core/src/main/java/com/jme3/environment/util/LightsDebugState.java
  11. 10 1
      jme3-core/src/main/java/com/jme3/light/AmbientLight.java
  12. 11 6
      jme3-core/src/main/java/com/jme3/light/DirectionalLight.java
  13. 13 13
      jme3-core/src/main/java/com/jme3/light/LightProbe.java
  14. 12 1
      jme3-core/src/main/java/com/jme3/light/PointLight.java
  15. 16 5
      jme3-core/src/main/java/com/jme3/light/SpotLight.java
  16. 6 6
      jme3-core/src/main/java/com/jme3/renderer/RenderManager.java
  17. 340 112
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java
  18. 136 50
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugger.java
  19. 26 10
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureInterJointsWire.java
  20. 215 57
      jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureNode.java
  21. 52 6
      jme3-core/src/main/java/com/jme3/shader/VarType.java
  22. 18 30
      jme3-core/src/main/java/com/jme3/shadow/AbstractShadowFilter.java
  23. 144 39
      jme3-core/src/main/java/com/jme3/system/AppSettings.java
  24. 38 0
      jme3-core/src/main/resources/Common/MatDefs/Misc/Dashed.j3md
  25. 23 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn
  26. 9 0
      jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag
  27. BIN
      jme3-core/src/main/resources/Common/Textures/lightbulb32.png
  28. 153 72
      jme3-core/src/plugins/java/com/jme3/audio/plugins/WAVLoader.java
  29. 7 9
      jme3-core/src/plugins/java/com/jme3/material/plugins/ShaderNodeDefinitionLoader.java
  30. 79 23
      jme3-examples/src/main/java/jme3test/export/TestOgreConvert.java
  31. 18 2
      jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java
  32. 9 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java
  33. 10 4
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java
  34. 117 0
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportLogCapture.java
  35. 29 13
      jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java
  36. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png
  37. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png
  38. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_DefaultDirectionalLight_f12.png
  39. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_HighRoughness_f12.png
  40. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_LowRoughness_f12.png
  41. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_UpdatedDirectionalLight_f12.png
  42. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithRealtimeBaking_f10.png
  43. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithoutRealtimeBaking_f10.png
  44. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromAbove_f1.png
  45. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromFront_f1.png
  46. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromRight_f1.png
  47. BIN
      jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestFog.testFog_f1.png

+ 68 - 0
.github/actions/tools/uploadToCentral.sh

@@ -0,0 +1,68 @@
+#! /bin/bash
+set -euo pipefail
+
+## Upload a deployment
+## from the "org.jmonkeyengine" namespace in Sonatype's OSSRH staging area
+## to Sonatype's Central Publisher Portal
+## so the deployment can be tested and then published or dropped.
+
+## IMPORTANT:  The upload request must originate
+## from the IP address used to stage the deployment to the staging area!
+
+# The required -p and -u flags on the command line
+# specify the password and username components of a "user token"
+# generated using the web interface at https://central.sonatype.com/account
+
+while getopts p:u: flag
+do
+    case "${flag}" in
+        p) centralPassword=${OPTARG};;
+        u) centralUsername=${OPTARG};;
+    esac
+done
+
+# Combine both components into a base64 "user token"
+# suitable for the Authorization header of a POST request:
+
+token=$(printf %s:%s "${centralUsername}" "${centralPassword}" | base64)
+
+# Send a POST request to upload the deployment:
+
+server='ossrh-staging-api.central.sonatype.com'
+endpoint='/manual/upload/defaultRepository/org.jmonkeyengine'
+url="https://${server}${endpoint}"
+
+statusCode=$(curl "${url}" \
+  --no-progress-meter \
+  --output postData1.txt \
+  --write-out '%{response_code}' \
+  --request POST \
+  --header 'accept: */*' \
+  --header "Authorization: Bearer ${token}" \
+  --data '')
+
+echo "Status code = ${statusCode}"
+echo 'Received data:'
+cat postData1.txt
+echo '[EOF]'
+
+# Retry if the default repo isn't found (status=400).
+
+if [ "${statusCode}" == "400" ]; then
+  echo "Will retry after 30 seconds."
+  sleep 30
+
+  statusCode2=$(curl "${url}" \
+    --no-progress-meter \
+    --output postData2.txt \
+    --write-out '%{response_code}' \
+    --request POST \
+    --header 'accept: */*' \
+    --header "Authorization: Bearer ${token}" \
+    --data '')
+
+  echo "Status code = ${statusCode2}"
+  echo 'Received data:'
+  cat postData2.txt
+  echo '[EOF]'
+fi

+ 56 - 58
.github/workflows/main.yml

@@ -16,8 +16,8 @@
 # >> Configure MINIO NATIVES SNAPSHOT
 #     OBJECTS_KEY=XXXXXX
 # >> Configure SONATYPE RELEASE
-#     OSSRH_PASSWORD=XXXXXX
-#     OSSRH_USERNAME=XXXXXX
+#     CENTRAL_PASSWORD=XXXXXX
+#     CENTRAL_USERNAME=XXXXXX
 # >> Configure SIGNING
 #     SIGNING_KEY=XXXXXX
 #     SIGNING_PASSWORD=XXXXXX
@@ -59,46 +59,41 @@ jobs:
   ScreenshotTests:
     name: Run Screenshot Tests
     runs-on: ubuntu-latest
+    container:
+      image: ghcr.io/onemillionworlds/opengl-docker-image:v1
     permissions:
       contents: read
     steps:
-    - uses: actions/checkout@v4
-    - name: Set up JDK 17
-      uses: actions/setup-java@v4
-      with:
-        java-version: '17'
-        distribution: 'temurin'
-    - name: Install Mesa3D
-      run: |
-        sudo apt-get update
-        sudo apt-get install -y mesa-utils libgl1-mesa-dri libgl1 libglx-mesa0 xvfb
-    - name: Set environment variables for Mesa3D
-      run: |
-        echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV
-        echo "MESA_LOADER_DRIVER_OVERRIDE=llvmpipe" >> $GITHUB_ENV
-    - name: Start xvfb
-      run: |
-        sudo Xvfb :99 -ac -screen 0 1024x768x16 &
-        export DISPLAY=:99
-        echo "DISPLAY=:99" >> $GITHUB_ENV
-    - name: Verify Mesa3D Installation
-      run: |
-        glxinfo | grep "OpenGL"
-    - name: Validate the Gradle wrapper
-      uses: gradle/actions/wrapper-validation@v3
-    - name: Test with Gradle Wrapper
-      run: |
-        ./gradlew :jme3-screenshot-test:screenshotTest
-    - name: Upload Test Reports
-      uses: actions/upload-artifact@master
-      if: always()
-      with:
-        name: screenshot-test-report
-        retention-days: 30
-        path: |
-          **/build/reports/**
-          **/build/changed-images/**
-          **/build/test-results/**
+      - uses: actions/checkout@v4
+      - name: Start xvfb
+        run: |
+          Xvfb :99 -ac -screen 0 1024x768x16 &
+          export DISPLAY=:99
+          echo "DISPLAY=:99" >> $GITHUB_ENV
+      - name: Report GL/Vulkan
+        run: |
+          set -x
+          echo "DISPLAY=$DISPLAY"
+          glxinfo | grep -E "OpenGL version|OpenGL renderer|OpenGL vendor" || true
+          vulkaninfo --summary || true
+          echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES"
+          echo "MESA_LOADER_DRIVER_OVERRIDE=$MESA_LOADER_DRIVER_OVERRIDE"
+          echo "GALLIUM_DRIVER=$GALLIUM_DRIVER"
+      - name: Validate the Gradle wrapper
+        uses: gradle/actions/wrapper-validation@v3
+      - name: Test with Gradle Wrapper
+        run: |
+          ./gradlew :jme3-screenshot-test:screenshotTest
+      - name: Upload Test Reports
+        uses: actions/upload-artifact@master
+        if: always()
+        with:
+          name: screenshot-test-report
+          retention-days: 30
+          path: |
+            **/build/reports/**
+            **/build/changed-images/**
+            **/build/test-results/**
   # Build the natives on android
   BuildAndroidNatives:
     name: Build natives for android
@@ -359,16 +354,16 @@ jobs:
           name: android-natives
           path: build/native
 
-      - name: Rebuild the maven artifacts and deploy them to the Sonatype repository
+      - name: Rebuild the maven artifacts and upload them to Sonatype's maven-snapshots repo
         run: |
-          if [ "${{ secrets.OSSRH_PASSWORD }}" = "" ];
+          if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ];
           then
-            echo "Configure the following secrets to enable deployment to Sonatype:"
-            echo "OSSRH_PASSWORD, OSSRH_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
+            echo "Configure the following secrets to enable uploading to Sonatype:"
+            echo "CENTRAL_PASSWORD, CENTRAL_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
           else
             ./gradlew publishMavenPublicationToSNAPSHOTRepository \
-            -PossrhPassword=${{ secrets.OSSRH_PASSWORD }} \
-            -PossrhUsername=${{ secrets.OSSRH_USERNAME }} \
+            -PcentralPassword=${{ secrets.CENTRAL_PASSWORD }} \
+            -PcentralUsername=${{ secrets.CENTRAL_USERNAME }} \
             -PsigningKey='${{ secrets.SIGNING_KEY }}' \
             -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
             -PuseCommitHashAsVersionName=true \
@@ -384,13 +379,13 @@ jobs:
     if: github.event_name == 'release'
     steps:
 
-      # We need to clone everything again for uploadToMaven.sh ...
+      # We need to clone everything again for uploadToCentral.sh ...
       - name: Clone the repo
         uses: actions/checkout@v4
         with:
           fetch-depth: 1
 
-      # Setup jdk 21 used for building Sonatype OSSRH artifacts
+      # Setup jdk 21 used for building Sonatype artifacts
       - name: Setup the java environment
         uses: actions/setup-java@v4
         with:
@@ -416,20 +411,23 @@ jobs:
           name: android-natives
           path: build/native
 
-      - name: Rebuild the maven artifacts and deploy them to Sonatype OSSRH
+      - name: Rebuild the maven artifacts and upload them to Sonatype's Central Publisher Portal
         run: |
-          if [ "${{ secrets.OSSRH_PASSWORD }}" = "" ];
+          if [ "${{ secrets.CENTRAL_PASSWORD }}" = "" ];
           then
-            echo "Configure the following secrets to enable deployment to Sonatype:"
-            echo "OSSRH_PASSWORD, OSSRH_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
+            echo "Configure the following secrets to enable uploading to Sonatype:"
+            echo "CENTRAL_PASSWORD, CENTRAL_USERNAME, SIGNING_KEY, SIGNING_PASSWORD"
           else
-            ./gradlew publishMavenPublicationToOSSRHRepository \
-            -PossrhPassword=${{ secrets.OSSRH_PASSWORD }} \
-            -PossrhUsername=${{ secrets.OSSRH_USERNAME }} \
-            -PsigningKey='${{ secrets.SIGNING_KEY }}' \
-            -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
-            -PuseCommitHashAsVersionName=true \
-            --console=plain --stacktrace
+            ./gradlew publishMavenPublicationToCentralRepository \
+              -PcentralPassword=${{ secrets.CENTRAL_PASSWORD }} \
+              -PcentralUsername=${{ secrets.CENTRAL_USERNAME }} \
+              -PsigningKey='${{ secrets.SIGNING_KEY }}' \
+              -PsigningPassword='${{ secrets.SIGNING_PASSWORD }}' \
+              -PuseCommitHashAsVersionName=true \
+              --console=plain --stacktrace
+            .github/actions/tools/uploadToCentral.sh \
+              -p '${{ secrets.CENTRAL_PASSWORD }}' \
+              -u '${{ secrets.CENTRAL_USERNAME }}'
           fi
 
       - name: Deploy to GitHub Releases

+ 1 - 1
.github/workflows/screenshot-test-comment.yml

@@ -21,7 +21,7 @@ jobs:
       contents: read
     steps:
       - name: Wait for GitHub to register the workflow run
-        run: sleep 15
+        run: sleep 120
 
       - name: Wait for Screenshot Tests to complete
         uses: lewagon/[email protected]

+ 20 - 12
common.gradle

@@ -157,27 +157,35 @@ publishing {
             version project.version
         }
     }
+
     repositories {
         maven {
             name = 'Dist'
             url = gradle.rootProject.projectDir.absolutePath + '/dist/maven'
         }
+
+        // Uploading to Sonatype relies on the existence of 2 properties
+        // (centralUsername and centralPassword)
+        // which should be set using -P options on the command line.
+
+        maven {
+            // for uploading release builds to the default repo in Sonatype's OSSRH staging area
+            credentials {
+                username = gradle.rootProject.hasProperty('centralUsername') ? centralUsername : 'Unknown user'
+                password = gradle.rootProject.hasProperty('centralPassword') ? centralPassword : 'Unknown password'
+            }
+            name = 'Central'
+            url = 'https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/'
+        }
         maven {
+            // for uploading snapshot builds to Sonatype's maven-snapshots repo
             credentials {
-                username = gradle.rootProject.hasProperty('ossrhUsername') ? ossrhUsername : 'Unknown user'
-                password = gradle.rootProject.hasProperty('ossrhPassword') ? ossrhPassword : 'Unknown password'
+                username = gradle.rootProject.hasProperty('centralUsername') ? centralUsername : 'Unknown user'
+                password = gradle.rootProject.hasProperty('centralPassword') ? centralPassword : 'Unknown password'
             }
-            name = 'OSSRH'
-            url = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2'
+            name = 'SNAPSHOT'
+            url = 'https://central.sonatype.com/repository/maven-snapshots/'
         }
-	maven {
-	    credentials {
-                username = gradle.rootProject.hasProperty('ossrhUsername') ? ossrhUsername : 'Unknown user'
-                password = gradle.rootProject.hasProperty('ossrhPassword') ? ossrhPassword : 'Unknown password'
-	    }
-	    name = 'SNAPSHOT'
-	    url = 'https://s01.oss.sonatype.org/content/repositories/snapshots/'
-	}
     }
 }
 

+ 148 - 83
jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2022 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -57,12 +57,11 @@ import java.util.prefs.BackingStoreException;
 import javax.swing.*;
 
 /**
- * <code>SettingsDialog</code> displays a Swing dialog box to interactively
- * configure the <code>AppSettings</code> of a desktop application before
- * <code>start()</code> is invoked.
- *
- * The <code>AppSettings</code> instance to be configured is passed to the
- * constructor.
+ * `AWTSettingsDialog` displays a Swing dialog box to interactively
+ * configure the `AppSettings` of a desktop application before
+ * `start()` is invoked.
+ * <p>
+ * The `AppSettings` instance to be configured is passed to the constructor.
  *
  * @see AppSettings
  * @author Mark Powell
@@ -71,14 +70,33 @@ import javax.swing.*;
  */
 public final class AWTSettingsDialog extends JFrame {
 
-    public static interface SelectionListener {
-
-        public void onSelection(int selection);
+    /**
+     * Listener interface for handling selection events from the settings dialog.
+     */
+    public interface SelectionListener {
+        /**
+         * Called when a selection is made in the settings dialog (OK or Cancel).
+         *
+         * @param selection The type of selection made: `NO_SELECTION`, `APPROVE_SELECTION`, or `CANCEL_SELECTION`.
+         */
+        void onSelection(int selection);
     }
 
     private static final Logger logger = Logger.getLogger(AWTSettingsDialog.class.getName());
     private static final long serialVersionUID = 1L;
-    public static final int NO_SELECTION = 0, APPROVE_SELECTION = 1, CANCEL_SELECTION = 2;
+
+    /**
+     * Indicates that no selection has been made yet.
+     */
+    public static final int NO_SELECTION = 0;
+    /**
+     * Indicates that the user approved the settings.
+     */
+    public static final int APPROVE_SELECTION = 1;
+    /**
+     * Indicates that the user canceled the settings dialog.
+     */
+    public static final int CANCEL_SELECTION = 2;
 
     // Resource bundle for i18n.
     ResourceBundle resourceBundle = ResourceBundle.getBundle("com.jme3.app/SettingsDialog");
@@ -86,8 +104,12 @@ public final class AWTSettingsDialog extends JFrame {
     // the instance being configured
     private final AppSettings source;
 
-    // Title Image
+    /**
+     * The URL of the image file to be displayed as a title icon in the dialog.
+     * Can be `null` if no image is desired.
+     */
     private URL imageFile = null;
+
     // Array of supported display modes
     private DisplayMode[] modes = null;
     private static final DisplayMode[] windowDefaults = new DisplayMode[] {
@@ -114,10 +136,24 @@ public final class AWTSettingsDialog extends JFrame {
     private int minWidth = 0;
     private int minHeight = 0;
 
+    /**
+     * Displays a settings dialog using the provided `AppSettings` source.
+     * Settings will be loaded from preferences.
+     *
+     * @param sourceSettings The `AppSettings` instance to configure.
+     * @return `true` if the user approved the settings, `false` otherwise.
+     */
     public static boolean showDialog(AppSettings sourceSettings) {
         return showDialog(sourceSettings, true);
     }
 
+    /**
+     * Displays a settings dialog using the provided `AppSettings` source.
+     *
+     * @param sourceSettings The `AppSettings` instance to configure.
+     * @param loadSettings   If `true`, settings will be loaded from preferences; otherwise, they will be merged.
+     * @return `true` if the user approved the settings, `false` otherwise.
+     */
     public static boolean showDialog(AppSettings sourceSettings, boolean loadSettings) {
         String iconPath = sourceSettings.getSettingsDialogImage();
         final URL iconUrl = JmeSystem.class.getResource(iconPath.startsWith("/") ? iconPath : "/" + iconPath);
@@ -127,10 +163,30 @@ public final class AWTSettingsDialog extends JFrame {
         return showDialog(sourceSettings, iconUrl, loadSettings);
     }
 
+    /**
+     * Displays a settings dialog using the provided `AppSettings` source and an image file path.
+     *
+     * @param sourceSettings The `AppSettings` instance to configure.
+     * @param imageFile      The path to the image file to use as the title of the dialog;
+     *                       `null` will result in no image being displayed.
+     * @param loadSettings   If `true`, settings will be loaded from preferences; otherwise, they will be merged.
+     * @return `true` if the user approved the settings, `false` otherwise.
+     */
     public static boolean showDialog(AppSettings sourceSettings, String imageFile, boolean loadSettings) {
         return showDialog(sourceSettings, getURL(imageFile), loadSettings);
     }
 
+    /**
+     * Displays a settings dialog using the provided `AppSettings` source and an image URL.
+     * This method blocks until the dialog is closed.
+     *
+     * @param sourceSettings The `AppSettings` instance to configure (not null).
+     * @param imageFile      The `URL` pointing to the image file to use as the title of the dialog;
+     *                       `null` will result in no image being displayed.
+     * @param loadSettings   If `true`, the dialog will copy settings from preferences. If `false`
+     *                       and preferences exist, they will be merged with the current settings.
+     * @return `true` if the user approved the settings, `false` otherwise (`CANCEL_SELECTION` or dialog close).
+     */
     public static boolean showDialog(AppSettings sourceSettings, URL imageFile, boolean loadSettings) {
         if (SwingUtilities.isEventDispatchThread()) {
             throw new IllegalStateException("Cannot run from EDT");
@@ -166,46 +222,47 @@ public final class AWTSettingsDialog extends JFrame {
         synchronized (lock) {
             while (!done.get()) {
                 try {
+                    // Wait until notified by the selection listener
                     lock.wait();
                 } catch (InterruptedException ex) {
+                    Thread.currentThread().interrupt();
+                    logger.log(Level.WARNING, "Settings dialog thread interrupted while waiting.", ex);
+                    return false; // Treat as cancel if interrupted
                 }
             }
         }
 
-        sourceSettings.copyFrom(settings);
+        // If approved, copy the modified settings back to the original source
+        if (result.get() == APPROVE_SELECTION) {
+            sourceSettings.copyFrom(settings);
+        }
 
-        return result.get() == AWTSettingsDialog.APPROVE_SELECTION;
+        return result.get() == APPROVE_SELECTION;
     }
 
     /**
-     * Instantiate a <code>SettingsDialog</code> for the primary display.
+     * Constructs a `SettingsDialog` for the primary display.
      *
-     * @param source
-     *            the <code>AppSettings</code> (not null)
-     * @param imageFile
-     *            the image file to use as the title of the dialog;
-     *            <code>null</code> will result in to image being displayed
-     * @param loadSettings
-     *            if true, copy the settings, otherwise merge them
-     * @throws IllegalArgumentException
-     *             if the source is <code>null</code>
+     * @param source       The `AppSettings` instance to configure (not null).
+     * @param imageFile    The path to the image file to use as the title of the dialog;
+     *                     `null` will result in no image being displayed.
+     * @param loadSettings If `true`, the dialog will copy settings from preferences. If `false`
+     *                     and preferences exist, they will be merged with the current settings.
+     * @throws IllegalArgumentException if `source` is `null`.
      */
     protected AWTSettingsDialog(AppSettings source, String imageFile, boolean loadSettings) {
         this(source, getURL(imageFile), loadSettings);
     }
 
     /**
-     * /** Instantiate a <code>SettingsDialog</code> for the primary display.
+     * Constructs a `SettingsDialog` for the primary display.
      *
-     * @param source
-     *            the <code>AppSettings</code> object (not null)
-     * @param imageFile
-     *            the image file to use as the title of the dialog;
-     *            <code>null</code> will result in to image being displayed
-     * @param loadSettings
-     *            if true, copy the settings, otherwise merge them
-     * @throws IllegalArgumentException
-     *             if the source is <code>null</code>
+     * @param source       The `AppSettings` instance to configure (not null).
+     * @param imageFile    The `URL` pointing to the image file to use as the title of the dialog;
+     *                     `null` will result in no image being displayed.
+     * @param loadSettings If `true`, the dialog will copy settings from preferences. If `false`
+     *                     and preferences exist, they will be merged with the current settings.
+     * @throws IllegalArgumentException if `source` is `null`.
      */
     protected AWTSettingsDialog(AppSettings source, URL imageFile, boolean loadSettings) {
         if (source == null) {
@@ -232,7 +289,10 @@ public final class AWTSettingsDialog extends JFrame {
         minHeight = source.getMinHeight();
 
         try {
+            logger.log(Level.INFO, "Loading AppSettings from PreferenceKey: {0}", appTitle);
             registrySettings.load(appTitle);
+            AppSettings.printPreferences(appTitle);
+
         } catch (BackingStoreException ex) {
             logger.log(Level.WARNING, "Failed to load settings", ex);
         }
@@ -355,8 +415,6 @@ public final class AWTSettingsDialog extends JFrame {
      * <code>init</code> creates the components to use the dialog.
      */
     private void createUI() {
-        GridBagConstraints gbc;
-
         JPanel mainPanel = new JPanel(new GridBagLayout());
 
         addWindowListener(new WindowAdapter() {
@@ -368,8 +426,9 @@ public final class AWTSettingsDialog extends JFrame {
             }
         });
 
-        if (source.getIcons() != null) {
-            safeSetIconImages(Arrays.asList((BufferedImage[]) source.getIcons()));
+        Object[] sourceIcons = source.getIcons();
+        if (sourceIcons != null && sourceIcons.length > 0) {
+            safeSetIconImages(Arrays.asList((BufferedImage[]) sourceIcons));
         }
 
         setTitle(MessageFormat.format(resourceBundle.getString("frame.title"), source.getTitle()));
@@ -419,7 +478,7 @@ public final class AWTSettingsDialog extends JFrame {
         gammaBox = new JCheckBox(resourceBundle.getString("checkbox.gamma"));
         gammaBox.setSelected(source.isGammaCorrection());
 
-        gbc = new GridBagConstraints();
+        GridBagConstraints gbc = new GridBagConstraints();
         gbc.weightx = 0.5;
         gbc.gridx = 0;
         gbc.gridwidth = 2;
@@ -493,7 +552,6 @@ public final class AWTSettingsDialog extends JFrame {
         // Set the button action listeners. Cancel disposes without saving, OK
         // saves.
         ok.addActionListener(new ActionListener() {
-
             @Override
             public void actionPerformed(ActionEvent e) {
                 if (verifyAndSaveCurrentSelection()) {
@@ -501,12 +559,13 @@ public final class AWTSettingsDialog extends JFrame {
                     dispose();
 
                     // System.gc() should be called to prevent "X Error of
-                    // failed request: RenderBadPicture (invalid Picture
-                    // parameter)"
+                    // failed request: RenderBadPicture (invalid Picture parameter)"
                     // on Linux when using AWT/Swing + GLFW.
                     // For more info see:
                     // https://github.com/LWJGL/lwjgl3/issues/149,
-                    // https://hub.jmonkeyengine.org/t/experimenting-lwjgl3/37275
+
+                    //  intentional double call. see this discussion:
+                    //  https://hub.jmonkeyengine.org/t/experimenting-lwjgl3/37275/12
                     System.gc();
                     System.gc();
                 }
@@ -514,7 +573,6 @@ public final class AWTSettingsDialog extends JFrame {
         });
 
         cancel.addActionListener(new ActionListener() {
-
             @Override
             public void actionPerformed(ActionEvent e) {
                 setUserSelection(CANCEL_SELECTION);
@@ -568,7 +626,6 @@ public final class AWTSettingsDialog extends JFrame {
                 colorDepthCombo.setSelectedItem(source.getBitsPerPixel() + " bpp");
             }
         });
-
     }
 
     /*
@@ -577,10 +634,8 @@ public final class AWTSettingsDialog extends JFrame {
      */
     private void safeSetIconImages(List<? extends Image> icons) {
         try {
-            // Due to Java bug 6445278, we try to set icon on our shared owner
-            // frame first.
-            // Otherwise, our alt-tab icon will be the Java default under
-            // Windows.
+            // Due to Java bug 6445278, we try to set icon on our shared owner frame first.
+            // Otherwise, our alt-tab icon will be the Java default under Windows.
             Window owner = getOwner();
             if (owner != null) {
                 Method setIconImages = owner.getClass().getMethod("setIconImages", List.class);
@@ -608,9 +663,9 @@ public final class AWTSettingsDialog extends JFrame {
         boolean vsync = vsyncBox.isSelected();
         boolean gamma = gammaBox.isSelected();
 
-        int width = Integer.parseInt(display.substring(0, display.indexOf(" x ")));
-        display = display.substring(display.indexOf(" x ") + 3);
-        int height = Integer.parseInt(display);
+        String[] parts = display.split(" x ");
+        int width = Integer.parseInt(parts[0]);
+        int height = Integer.parseInt(parts[1]);
 
         String depthString = (String) colorDepthCombo.getSelectedItem();
         int depth = -1;
@@ -639,21 +694,20 @@ public final class AWTSettingsDialog extends JFrame {
         }
 
         // FIXME: Does not work in Linux
-        /*
-         * if (!fullscreen) { //query the current bit depth of the desktop int
-         * curDepth = GraphicsEnvironment.getLocalGraphicsEnvironment()
-         * .getDefaultScreenDevice().getDisplayMode().getBitDepth(); if (depth >
-         * curDepth) { showError(this,"Cannot choose a higher bit depth in
-         * windowed " + "mode than your current desktop bit depth"); return
-         * false; } }
-         */
-
-        boolean valid = false;
+//        if (!fullscreen) { //query the current bit depth of the desktop int
+//            curDepth = GraphicsEnvironment.getLocalGraphicsEnvironment()
+//                    .getDefaultScreenDevice().getDisplayMode().getBitDepth();
+//            if (depth > curDepth) {
+//                showError(this, "Cannot choose a higher bit depth in
+//                        windowed" + "mode than your current desktop bit depth");
+//                return false;
+//            }
+//        }
+
+        boolean valid = true;
 
         // test valid display mode when going full screen
-        if (!fullscreen) {
-            valid = true;
-        } else {
+        if (fullscreen) {
             GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
             valid = device.isFullScreenSupported();
         }
@@ -673,7 +727,10 @@ public final class AWTSettingsDialog extends JFrame {
             String appTitle = source.getTitle();
 
             try {
+                logger.log(Level.INFO, "Saving AppSettings to PreferencesKey: {0}", appTitle);
                 source.save(appTitle);
+                AppSettings.printPreferences(appTitle);
+
             } catch (BackingStoreException ex) {
                 logger.log(Level.WARNING, "Failed to save setting changes", ex);
             }
@@ -769,7 +826,9 @@ public final class AWTSettingsDialog extends JFrame {
     private void updateAntialiasChoices() {
         // maybe in the future will add support for determining this info
         // through PBuffer
-        String[] choices = new String[] { resourceBundle.getString("antialias.disabled"), "2x", "4x", "6x", "8x", "16x" };
+        String[] choices = new String[] {
+                resourceBundle.getString("antialias.disabled"), "2x", "4x", "6x", "8x", "16x"
+        };
         antialiasCombo.setModel(new DefaultComboBoxModel<>(choices));
         antialiasCombo.setSelectedItem(choices[Math.min(source.getSamples() / 2, 5)]);
     }
@@ -792,6 +851,12 @@ public final class AWTSettingsDialog extends JFrame {
         return url;
     }
 
+    /**
+     * Displays an error message dialog to the user.
+     *
+     * @param parent  The parent `Component` for the dialog.
+     * @param message The message `String` to display.
+     */
     private static void showError(java.awt.Component parent, String message) {
         JOptionPane.showMessageDialog(parent, message, "Error", JOptionPane.ERROR_MESSAGE);
     }
@@ -852,7 +917,7 @@ public final class AWTSettingsDialog extends JFrame {
      * Returns every possible bit depth for the given resolution.
      */
     private static String[] getDepths(String resolution, DisplayMode[] modes) {
-        List<String> depths = new ArrayList<>(4);
+        Set<String> depths = new LinkedHashSet<>(4); // Use LinkedHashSet for uniqueness and order
         for (DisplayMode mode : modes) {
             int bitDepth = mode.getBitDepth();
             if (bitDepth == DisplayMode.BIT_DEPTH_MULTI) {
@@ -865,12 +930,8 @@ public final class AWTSettingsDialog extends JFrame {
                 continue;
             }
             String res = mode.getWidth() + " x " + mode.getHeight();
-            if (!res.equals(resolution)) {
-                continue;
-            }
-            String depth = bitDepth + " bpp";
-            if (!depths.contains(depth)) {
-                depths.add(depth);
+            if (res.equals(resolution)) {
+                depths.add(bitDepth + " bpp");
             }
         }
 
@@ -884,10 +945,15 @@ public final class AWTSettingsDialog extends JFrame {
     }
 
     /**
-     * Returns every possible refresh rate for the given resolution.
+     * Returns every possible unique refresh rate string ("XX Hz" or "???")
+     * for the given resolution from an array of `DisplayMode`s.
+     *
+     * @param resolution The resolution string (e.g., "1280 x 720") to filter by.
+     * @param modes      The array of `DisplayMode`s to process.
+     * @return An array of unique refresh rate strings.
      */
     private static String[] getFrequencies(String resolution, DisplayMode[] modes) {
-        List<String> freqs = new ArrayList<>(4);
+        Set<String> freqs = new LinkedHashSet<>(4); // Use LinkedHashSet for uniqueness and order
         for (DisplayMode mode : modes) {
             String res = mode.getWidth() + " x " + mode.getHeight();
             String freq;
@@ -896,20 +962,19 @@ public final class AWTSettingsDialog extends JFrame {
             } else {
                 freq = mode.getRefreshRate() + " Hz";
             }
-            if (res.equals(resolution) && !freqs.contains(freq)) {
-                freqs.add(freq);
-            }
+            freqs.add(freq);
         }
 
         return freqs.toArray(new String[0]);
     }
 
     /**
-     * Chooses the closest frequency to 60 Hz.
-     * 
-     * @param resolution
-     * @param modes
-     * @return
+     * Chooses the closest known refresh rate to 60 Hz for a given resolution.
+     * If no known refresh rates are found for the resolution, returns `null`.
+     *
+     * @param resolution The resolution string (e.g., "1280 x 720") to find the best frequency for.
+     * @param modes      The array of `DisplayMode`s to search within.
+     * @return The best frequency string (e.g., "60 Hz") or `null` if no suitable frequency is found.
      */
     private static String getBestFrequency(String resolution, DisplayMode[] modes) {
         int closest = Integer.MAX_VALUE;

+ 54 - 7
jme3-core/src/main/java/com/jme3/anim/MatrixJointModelTransform.java

@@ -1,3 +1,34 @@
+/*
+ * Copyright (c) 2009-2025 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
 package com.jme3.anim;
 
 import com.jme3.anim.util.JointModelTransform;
@@ -5,21 +36,36 @@ import com.jme3.math.Matrix4f;
 import com.jme3.math.Transform;
 
 /**
- * This JointModelTransform implementation accumulate joints transforms in a Matrix4f to properly
- * support non uniform scaling in an armature hierarchy
+ * An implementation of {@link JointModelTransform} that accumulates joint transformations
+ * into a {@link Matrix4f}. This approach is particularly useful for correctly handling
+ * non-uniform scaling within an armature hierarchy, as {@code Matrix4f} can represent
+ * non-uniform scaling directly, unlike {@link Transform}, which typically handles
+ * uniform scaling.
+ * <p>
+ * This class maintains a single {@link Matrix4f} to represent the accumulated
+ * model-space transform of the joint it's associated with.
  */
 public class MatrixJointModelTransform implements JointModelTransform {
 
-    final private Matrix4f modelTransformMatrix = new Matrix4f();
-    final private Transform modelTransform = new Transform();
+    /**
+     * The model-space transform of the joint represented as a Matrix4f.
+     * This matrix accumulates the local transform of the joint and the model transform
+     * of its parent.
+     */
+    private final Matrix4f modelTransformMatrix = new Matrix4f();
+    /**
+     * A temporary Transform instance used for converting the modelTransformMatrix
+     * to a Transform object when {@link #getModelTransform()} is called.
+     */
+    private final Transform modelTransform = new Transform();
 
     @Override
     public void updateModelTransform(Transform localTransform, Joint parent) {
         localTransform.toTransformMatrix(modelTransformMatrix);
         if (parent != null) {
-            ((MatrixJointModelTransform) parent.getJointModelTransform()).getModelTransformMatrix().mult(modelTransformMatrix, modelTransformMatrix);
+            MatrixJointModelTransform transform = (MatrixJointModelTransform) parent.getJointModelTransform();
+            transform.getModelTransformMatrix().mult(modelTransformMatrix, modelTransformMatrix);
         }
-
     }
 
     @Override
@@ -31,7 +77,8 @@ public class MatrixJointModelTransform implements JointModelTransform {
     public void applyBindPose(Transform localTransform, Matrix4f inverseModelBindMatrix, Joint parent) {
         modelTransformMatrix.set(inverseModelBindMatrix).invertLocal(); // model transform = model bind
         if (parent != null) {
-            ((MatrixJointModelTransform) parent.getJointModelTransform()).getModelTransformMatrix().invert().mult(modelTransformMatrix, modelTransformMatrix);
+            MatrixJointModelTransform transform = (MatrixJointModelTransform) parent.getJointModelTransform();
+            transform.getModelTransformMatrix().invert().mult(modelTransformMatrix, modelTransformMatrix);
         }
         localTransform.fromTransformMatrix(modelTransformMatrix);
     }

+ 181 - 150
jme3-core/src/main/java/com/jme3/anim/SkinningControl.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2023 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,12 +31,21 @@
  */
 package com.jme3.anim;
 
-import com.jme3.export.*;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
 import com.jme3.material.MatParamOverride;
 import com.jme3.math.FastMath;
 import com.jme3.math.Matrix4f;
-import com.jme3.renderer.*;
-import com.jme3.scene.*;
+import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.RendererException;
+import com.jme3.renderer.ViewPort;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.scene.control.AbstractControl;
 import com.jme3.scene.mesh.IndexBuffer;
@@ -53,64 +62,77 @@ import java.util.logging.Level;
 import java.util.logging.Logger;
 
 /**
- * The Skinning control deforms a model according to an armature, It handles the
- * computation of the deformation matrices and performs the transformations on
- * the mesh
+ * The `SkinningControl` deforms a 3D model according to an {@link Armature}. It manages the
+ * computation of deformation matrices and applies these transformations to the mesh,
+ * supporting both software and hardware-accelerated skinning.
+ *
+ * <p>
+ * **Software Skinning:** Performed on the CPU, offering broader compatibility but
+ * potentially lower performance for complex models.
  * <p>
- * It can perform software skinning or Hardware skinning
+ * **Hardware Skinning:** Utilizes the GPU for deformation, providing significantly
+ * better performance but requiring shader support and having a limit on the number
+ * of bones (typically 255 in common shaders).
  *
- * @author Rémy Bouquet Based on SkeletonControl by Kirill Vainer
+ * @author Nehon
  */
-public class SkinningControl extends AbstractControl implements Cloneable, JmeCloneable {
+public class SkinningControl extends AbstractControl implements JmeCloneable {
 
     private static final Logger logger = Logger.getLogger(SkinningControl.class.getName());
 
+    /**
+     * The maximum number of bones supported for hardware skinning in common shaders.
+     */
+    private static final int MAX_BONES_HW_SKINNING_SUPPORT = 255;
+
     /**
      * The armature of the model.
      */
     private Armature armature;
 
     /**
-     * List of geometries affected by this control.
+     * A list of geometries that this control will deform.
      */
     private SafeArrayList<Geometry> targets = new SafeArrayList<>(Geometry.class);
 
     /**
-     * Used to track when a mesh was updated. Meshes are only updated if they
+     * Used to track when a mesh needs to be updated. Meshes are only updated if they
      * are visible in at least one camera.
      */
-    private boolean wasMeshUpdated = false;
+    private boolean meshUpdateRequired = true;
 
     /**
-     * User wishes to use hardware skinning if available.
+     * Indicates whether hardware skinning is preferred. If `true` and the GPU
+     * supports it, hardware skinning will be enabled.
      */
-    private transient boolean hwSkinningDesired = true;
+    private transient boolean hwSkinningPreferred = true;
 
     /**
-     * Hardware skinning is currently being used.
+     * Indicates if hardware skinning is currently active and being used.
      */
     private transient boolean hwSkinningEnabled = false;
 
     /**
-     * Hardware skinning was tested on this GPU, results
-     * are stored in {@link #hwSkinningSupported} variable.
+     * Flag indicating whether hardware skinning compatibility has been tested
+     * on the current GPU. Results are stored in {@link #hwSkinningSupported}.
      */
     private transient boolean hwSkinningTested = false;
 
     /**
-     * If hardware skinning was {@link #hwSkinningTested tested}, then
-     * this variable will be set to true if supported, and false if otherwise.
+     * Stores the result of the hardware skinning compatibility test. `true` if
+     * supported, `false` otherwise. This is only valid after
+     * {@link #hwSkinningTested} is `true`.
      */
     private transient boolean hwSkinningSupported = false;
 
     /**
-     * Bone offset matrices, recreated each frame
+     * Bone offset matrices, computed each frame to deform the mesh based on
+     * the armature's current pose.
      */
-    private transient Matrix4f[] offsetMatrices;
-
+    private transient Matrix4f[] boneOffsetMatrices;
 
-    private MatParamOverride numberOfJointsParam;
-    private MatParamOverride jointMatricesParam;
+    private MatParamOverride numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
+    private MatParamOverride jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
 
     /**
      * Serialization only. Do not use.
@@ -119,26 +141,26 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Creates an armature control. The list of targets will be acquired
-     * automatically when the control is attached to a node.
+     * Creates a new `SkinningControl` for the given armature.
      *
-     * @param armature the armature
+     * @param armature The armature that drives the deformation (not null).
      */
     public SkinningControl(Armature armature) {
         if (armature == null) {
-            throw new IllegalArgumentException("armature cannot be null");
+            throw new IllegalArgumentException("armature cannot be null.");
         }
         this.armature = armature;
-        this.numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
-        this.jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
     }
 
-
-    private void switchToHardware() {
+    /**
+     * Configures the material parameters and meshes for hardware skinning.
+     */
+    private void enableHardwareSkinning() {
         numberOfJointsParam.setEnabled(true);
         jointMatricesParam.setEnabled(true);
 
-        // Next full 10 bones (e.g. 30 on 24 bones)
+        // Calculate the number of bones rounded up to the nearest multiple of 10.
+        // This is often required by shaders for array uniform declarations.
         int numBones = ((armature.getJointCount() / 10) + 1) * 10;
         numberOfJointsParam.setValue(numBones);
 
@@ -150,7 +172,10 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         }
     }
 
-    private void switchToSoftware() {
+    /**
+     * Configures the material parameters and meshes for software skinning.
+     */
+    private void enableSoftwareSkinning() {
         numberOfJointsParam.setEnabled(false);
         jointMatricesParam.setEnabled(false);
 
@@ -162,22 +187,34 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         }
     }
 
-    private boolean testHardwareSupported(RenderManager rm) {
-
-        //Only 255 bones max supported with hardware skinning
-        if (armature.getJointCount() > 255) {
+    /**
+     * Tests if hardware skinning is supported by the GPU for the current spatial.
+     *
+     * @param renderManager the RenderManager instance
+     * @return true if hardware skinning is supported, false otherwise
+     */
+    private boolean testHardwareSupported(RenderManager renderManager) {
+        // Only 255 bones max supported with hardware skinning in common shaders.
+        if (armature.getJointCount() > MAX_BONES_HW_SKINNING_SUPPORT) {
+            logger.log(Level.INFO, "Hardware skinning not supported for {0}: Too many bones ({1} > 255).",
+                    new Object[]{spatial, armature.getJointCount()});
             return false;
         }
 
-        switchToHardware();
+        // Temporarily enable hardware skinning to test shader compilation.
+        enableHardwareSkinning();
+        boolean hwSkinningEngaged = false;
 
         try {
-            rm.preloadScene(spatial);
-            return true;
-        } catch (RendererException e) {
-            logger.log(Level.WARNING, "Could not enable HW skinning due to shader compile error:", e);
-            return false;
+            renderManager.preloadScene(spatial);
+            logger.log(Level.INFO, "Hardware skinning engaged for {0}", spatial);
+            hwSkinningEngaged = true;
+
+        } catch (RendererException ex) {
+            logger.log(Level.WARNING, "Could not enable HW skinning due to shader compile error: ", ex);
         }
+
+        return hwSkinningEngaged;
     }
 
     /**
@@ -190,7 +227,7 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      * @see #isHardwareSkinningUsed()
      */
     public void setHardwareSkinningPreferred(boolean preferred) {
-        hwSkinningDesired = preferred;
+        hwSkinningPreferred = preferred;
     }
 
     /**
@@ -199,7 +236,7 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      * @see #setHardwareSkinningPreferred(boolean)
      */
     public boolean isHardwareSkinningPreferred() {
-        return hwSkinningDesired;
+        return hwSkinningPreferred;
     }
 
     /**
@@ -209,25 +246,21 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         return hwSkinningEnabled;
     }
 
-
     /**
-     * If specified the geometry has an animated mesh, add its mesh and material
-     * to the lists of animation targets.
+     * Recursively finds and adds animated geometries to the targets list.
+     *
+     * @param sp The spatial to search within.
      */
-    private void findTargets(Geometry geometry) {
-        Mesh mesh = geometry.getMesh();
-        if (mesh != null && mesh.isAnimated()) {
-            targets.add(geometry);
-        }
-
-    }
-
-    private void findTargets(Node node) {
-        for (Spatial child : node.getChildren()) {
-            if (child instanceof Geometry) {
-                findTargets((Geometry) child);
-            } else if (child instanceof Node) {
-                findTargets((Node) child);
+    private void collectAnimatedGeometries(Spatial sp) {
+        if (sp instanceof Geometry) {
+            Geometry geo = (Geometry) sp;
+            Mesh mesh = geo.getMesh();
+            if (mesh != null && mesh.isAnimated()) {
+                targets.add(geo);
+            }
+        } else if (sp instanceof Node) {
+            for (Spatial child : ((Node) sp).getChildren()) {
+                collectAnimatedGeometries(child);
             }
         }
     }
@@ -236,65 +269,72 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     public void setSpatial(Spatial spatial) {
         Spatial oldSpatial = this.spatial;
         super.setSpatial(spatial);
-        updateTargetsAndMaterials(spatial);
+        updateAnimationTargets(spatial);
 
         if (oldSpatial != null) {
+            // Ensure parameters are removed from the old spatial to prevent memory leaks
             oldSpatial.removeMatParamOverride(numberOfJointsParam);
             oldSpatial.removeMatParamOverride(jointMatricesParam);
         }
 
         if (spatial != null) {
-            spatial.removeMatParamOverride(numberOfJointsParam);
-            spatial.removeMatParamOverride(jointMatricesParam);
+            // Add parameters to the new spatial. No need to remove first if they are not already present.
             spatial.addMatParamOverride(numberOfJointsParam);
             spatial.addMatParamOverride(jointMatricesParam);
         }
     }
 
+    /**
+     * Performs software skinning updates.
+     */
     private void controlRenderSoftware() {
         resetToBind(); // reset morph meshes to bind pose
 
-        offsetMatrices = armature.computeSkinningMatrices();
+        boneOffsetMatrices = armature.computeSkinningMatrices();
 
         for (Geometry geometry : targets) {
             Mesh mesh = geometry.getMesh();
-            // NOTE: This assumes code higher up has
-            // already ensured this mesh is animated.
-            // Otherwise a crash will happen in skin update.
-            softwareSkinUpdate(mesh, offsetMatrices);
+            // NOTE: This assumes code higher up has already ensured this mesh is animated.
+            // Otherwise, a crash will happen in skin update.
+            applySoftwareSkinning(mesh, boneOffsetMatrices);
         }
     }
 
+    /**
+     * Prepares parameters for hardware skinning.
+     */
     private void controlRenderHardware() {
-        offsetMatrices = armature.computeSkinningMatrices();
-        jointMatricesParam.setValue(offsetMatrices);
+        boneOffsetMatrices = armature.computeSkinningMatrices();
+        jointMatricesParam.setValue(boneOffsetMatrices);
     }
 
     @Override
     protected void controlRender(RenderManager rm, ViewPort vp) {
-        if (!wasMeshUpdated) {
-            updateTargetsAndMaterials(spatial);
+        if (meshUpdateRequired) {
+            updateAnimationTargets(spatial);
 
             // Prevent illegal cases. These should never happen.
-            assert hwSkinningTested || (!hwSkinningTested && !hwSkinningSupported && !hwSkinningEnabled);
-            assert !hwSkinningEnabled || (hwSkinningEnabled && hwSkinningTested && hwSkinningSupported);
+            assert hwSkinningTested || (!hwSkinningSupported && !hwSkinningEnabled);
+            assert !hwSkinningEnabled || (hwSkinningTested && hwSkinningSupported);
 
-            if (hwSkinningDesired && !hwSkinningTested) {
+            if (hwSkinningPreferred && !hwSkinningTested) {
+                // If hardware skinning is preferred and hasn't been tested yet, test it.
                 hwSkinningTested = true;
                 hwSkinningSupported = testHardwareSupported(rm);
 
                 if (hwSkinningSupported) {
                     hwSkinningEnabled = true;
-
-                    Logger.getLogger(SkinningControl.class.getName()).log(Level.INFO, "Hardware skinning engaged for {0}", spatial);
                 } else {
-                    switchToSoftware();
+                    enableSoftwareSkinning();
                 }
-            } else if (hwSkinningDesired && hwSkinningSupported && !hwSkinningEnabled) {
-                switchToHardware();
+            } else if (hwSkinningPreferred && hwSkinningSupported && !hwSkinningEnabled) {
+                // If hardware skinning is preferred, supported, but not yet enabled, enable it.
+                enableHardwareSkinning();
                 hwSkinningEnabled = true;
-            } else if (!hwSkinningDesired && hwSkinningEnabled) {
-                switchToSoftware();
+
+            } else if (!hwSkinningPreferred && hwSkinningEnabled) {
+                // If hardware skinning is no longer preferred but is enabled, switch to software.
+                enableSoftwareSkinning();
                 hwSkinningEnabled = false;
             }
 
@@ -304,17 +344,22 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
                 controlRenderSoftware();
             }
 
-            wasMeshUpdated = true;
+            meshUpdateRequired = false; // Reset flag after update
         }
     }
 
     @Override
     protected void controlUpdate(float tpf) {
-        wasMeshUpdated = false;
+        meshUpdateRequired = true; // Mark for mesh update on next render pass
         armature.update();
     }
 
-    //only do this for software updates
+    /**
+     * Resets the vertex, normal, and tangent buffers of animated meshes to their
+     * original bind pose. This is crucial for software skinning to ensure
+     * transformations are applied from a consistent base.
+     * This method is only applied when performing software updates.
+     */
     void resetToBind() {
         for (Geometry geometry : targets) {
             Mesh mesh = geometry.getMesh();
@@ -378,51 +423,51 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Access the attachments node of the named bone. If the bone doesn't
-     * already have an attachments node, create one and attach it to the scene
-     * graph. Models and effects attached to the attachments node will follow
-     * the bone's motions.
+     * Provides access to the attachment node for a specific joint in the armature.
+     * If an attachment node does not already exist for the named joint, one will be
+     * created and attached to the scene graph. Models or effects attached to this
+     * node will follow the motion of the corresponding bone.
      *
      * @param jointName the name of the joint
      * @return the attachments node of the joint
      */
     public Node getAttachmentsNode(String jointName) {
-        Joint b = armature.getJoint(jointName);
-        if (b == null) {
-            throw new IllegalArgumentException("Given bone name does not exist "
-                    + "in the armature.");
+        Joint joint = armature.getJoint(jointName);
+        if (joint == null) {
+            throw new IllegalArgumentException(
+                    "Given joint name '" + jointName + "' does not exist in the armature.");
         }
 
-        updateTargetsAndMaterials(spatial);
-        int boneIndex = armature.getJointIndex(b);
-        Node n = b.getAttachmentsNode(boneIndex, targets);
-        /*
-         * Select a node to parent the attachments node.
-         */
+        updateAnimationTargets(spatial);
+        int jointIndex = armature.getJointIndex(joint);
+        Node attachNode = joint.getAttachmentsNode(jointIndex, targets);
+
+        // Determine the appropriate parent for the attachment node.
         Node parent;
         if (spatial instanceof Node) {
             parent = (Node) spatial; // the usual case
         } else {
             parent = spatial.getParent();
         }
-        parent.attachChild(n);
+        parent.attachChild(attachNode);
 
-        return n;
+        return attachNode;
     }
 
     /**
-     * returns the armature of this control
+     * Returns the armature associated with this skinning control.
      *
-     * @return the pre-existing instance
+     * @return The pre-existing `Armature` instance.
      */
     public Armature getArmature() {
         return armature;
     }
 
     /**
-     * Enumerate the target meshes of this control.
+     * Returns an array containing all the target meshes that this control
+     * is currently affecting.
      *
-     * @return a new array
+     * @return A new array of `Mesh` objects.
      */
     public Mesh[] getTargets() {
         Mesh[] result = new Mesh[targets.size()];
@@ -437,30 +482,31 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
     }
 
     /**
-     * Update the mesh according to the given transformation matrices
+     * Applies software skinning transformations to the given mesh using the
+     * provided bone offset matrices.
      *
-     * @param mesh           then mesh
-     * @param offsetMatrices the transformation matrices to apply
+     * @param mesh           The mesh to deform.
+     * @param offsetMatrices The array of transformation matrices for each bone.
      */
-    private void softwareSkinUpdate(Mesh mesh, Matrix4f[] offsetMatrices) {
+    private void applySoftwareSkinning(Mesh mesh, Matrix4f[] offsetMatrices) {
 
         VertexBuffer tb = mesh.getBuffer(Type.Tangent);
         if (tb == null) {
-            //if there are no tangents use the classic skinning
+            // if there are no tangents use the classic skinning
             applySkinning(mesh, offsetMatrices);
         } else {
-            //if there are tangents use the skinning with tangents
+            // if there are tangents use the skinning with tangents
             applySkinningTangents(mesh, offsetMatrices, tb);
         }
-
-
     }
 
     /**
-     * Method to apply skinning transforms to a mesh's buffers
+     * Applies skinning transformations to a mesh's position and normal buffers.
+     * This method iterates through each vertex, applies the weighted sum of
+     * bone transformations, and updates the vertex buffers.
      *
-     * @param mesh           the mesh
-     * @param offsetMatrices the offset matrices to apply
+     * @param mesh           The mesh to apply skinning to.
+     * @param offsetMatrices The bone offset matrices to use for transformation.
      */
     private void applySkinning(Mesh mesh, Matrix4f[] offsetMatrices) {
         int maxWeightsPerVert = mesh.getMaxNumWeights();
@@ -555,19 +601,16 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
 
         vb.updateData(fvb);
         nb.updateData(fnb);
-
     }
 
     /**
-     * Specific method for skinning with tangents to avoid cluttering the
-     * classic skinning calculation with null checks that would slow down the
-     * process even if tangents don't have to be computed. Also the iteration
-     * has additional indexes since tangent has 4 components instead of 3 for
-     * pos and norm
+     * Applies skinning transformations to a mesh's position, normal, and tangent buffers.
+     * This method is specifically designed for meshes that include tangent data,
+     * ensuring proper deformation of tangents alongside positions and normals.
      *
-     * @param mesh           the mesh
-     * @param offsetMatrices the offsetMatrices to apply
-     * @param tb             the tangent vertexBuffer
+     * @param mesh           The mesh to apply skinning to.
+     * @param offsetMatrices The bone offset matrices to use for transformation.
+     * @param tb             The tangent `VertexBuffer`.
      */
     private void applySkinningTangents(Mesh mesh, Matrix4f[] offsetMatrices, VertexBuffer tb) {
         int maxWeightsPerVert = mesh.getMaxNumWeights();
@@ -594,7 +637,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         FloatBuffer ftb = (FloatBuffer) tb.getData();
         ftb.rewind();
 
-
         // get boneIndexes and weights for mesh
         IndexBuffer ib = IndexBuffer.wrapIndexBuffer(mesh.getBuffer(Type.BoneIndex).getData());
         FloatBuffer wb = (FloatBuffer) mesh.getBuffer(Type.BoneWeight).getData();
@@ -605,8 +647,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         int idxWeights = 0;
 
         TempVars vars = TempVars.get();
-
-
         float[] posBuf = vars.skinPositions;
         float[] normBuf = vars.skinNormals;
         float[] tanBuf = vars.skinTangents;
@@ -723,9 +763,6 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         super.write(ex);
         OutputCapsule oc = ex.getCapsule(this);
         oc.write(armature, "armature", null);
-
-        oc.write(numberOfJointsParam, "numberOfBonesParam", null);
-        oc.write(jointMatricesParam, "boneMatricesParam", null);
     }
 
     /**
@@ -741,15 +778,13 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
         InputCapsule in = im.getCapsule(this);
         armature = (Armature) in.readSavable("armature", null);
 
-        numberOfJointsParam = (MatParamOverride) in.readSavable("numberOfBonesParam", null);
-        jointMatricesParam = (MatParamOverride) in.readSavable("boneMatricesParam", null);
-
-        if (numberOfJointsParam == null) {
-            numberOfJointsParam = new MatParamOverride(VarType.Int, "NumberOfBones", null);
-            jointMatricesParam = new MatParamOverride(VarType.Matrix4Array, "BoneMatrices", null);
-            getSpatial().addMatParamOverride(numberOfJointsParam);
-            getSpatial().addMatParamOverride(jointMatricesParam);
+        for (MatParamOverride mpo : spatial.getLocalMatParamOverrides().getArray()) {
+            if (mpo.getName().equals("NumberOfBones") || mpo.getName().equals("BoneMatrices")) {
+                spatial.removeMatParamOverride(mpo);
+            }
         }
+        spatial.addMatParamOverride(numberOfJointsParam);
+        spatial.addMatParamOverride(jointMatricesParam);
     }
 
     /**
@@ -757,13 +792,9 @@ public class SkinningControl extends AbstractControl implements Cloneable, JmeCl
      *
      * @param spatial the controlled spatial
      */
-    private void updateTargetsAndMaterials(Spatial spatial) {
+    private void updateAnimationTargets(Spatial spatial) {
         targets.clear();
-
-        if (spatial instanceof Node) {
-            findTargets((Node) spatial);
-        } else if (spatial instanceof Geometry) {
-            findTargets((Geometry) spatial);
-        }
+        collectAnimatedGeometries(spatial);
     }
+
 }

+ 92 - 39
jme3-core/src/main/java/com/jme3/effect/shapes/EmitterMeshFaceShape.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -36,6 +36,7 @@ import com.jme3.math.Vector3f;
 import com.jme3.scene.Mesh;
 import com.jme3.scene.VertexBuffer.Type;
 import com.jme3.util.BufferUtils;
+
 import java.util.ArrayList;
 import java.util.List;
 
@@ -52,79 +53,131 @@ public class EmitterMeshFaceShape extends EmitterMeshVertexShape {
     }
 
     /**
-     * Constructor. It stores a copy of vertex list of all meshes.
-     * @param meshes
-     *        a list of meshes that will form the emitter's shape
+     * Constructor. Initializes the emitter shape with a list of meshes.
+     * The vertices and normals for all triangles of these meshes are
+     * extracted and stored internally.
+     *
+     * @param meshes a list of {@link Mesh} objects that will define the
+     * shape from which particles are emitted.
      */
     public EmitterMeshFaceShape(List<Mesh> meshes) {
         super(meshes);
     }
 
+    /**
+     * Sets the meshes for this emitter shape. This method extracts all
+     * triangle vertices and computes their normals, storing them internally
+     * for subsequent particle emission.
+     *
+     * @param meshes a list of {@link Mesh} objects to set as the emitter's shape.
+     */
     @Override
     public void setMeshes(List<Mesh> meshes) {
         this.vertices = new ArrayList<List<Vector3f>>(meshes.size());
         this.normals = new ArrayList<List<Vector3f>>(meshes.size());
+
         for (Mesh mesh : meshes) {
             Vector3f[] vertexTable = BufferUtils.getVector3Array(mesh.getFloatBuffer(Type.Position));
             int[] indices = new int[3];
-            List<Vector3f> vertices = new ArrayList<>(mesh.getTriangleCount() * 3);
-            List<Vector3f> normals = new ArrayList<>(mesh.getTriangleCount());
+            List<Vector3f> meshVertices = new ArrayList<>(mesh.getTriangleCount() * 3);
+            List<Vector3f> meshNormals = new ArrayList<>(mesh.getTriangleCount());
+
             for (int i = 0; i < mesh.getTriangleCount(); ++i) {
                 mesh.getTriangle(i, indices);
-                vertices.add(vertexTable[indices[0]]);
-                vertices.add(vertexTable[indices[1]]);
-                vertices.add(vertexTable[indices[2]]);
-                normals.add(FastMath.computeNormal(vertexTable[indices[0]], vertexTable[indices[1]], vertexTable[indices[2]]));
+
+                Vector3f v1 = vertexTable[indices[0]];
+                Vector3f v2 = vertexTable[indices[1]];
+                Vector3f v3 = vertexTable[indices[2]];
+
+                // Add all three vertices of the triangle
+                meshVertices.add(v1);
+                meshVertices.add(v2);
+                meshVertices.add(v3);
+
+                // Compute and add the normal for the current triangle face
+                meshNormals.add(FastMath.computeNormal(v1, v2, v3));
             }
-            this.vertices.add(vertices);
-            this.normals.add(normals);
+            this.vertices.add(meshVertices);
+            this.normals.add(meshNormals);
         }
     }
 
     /**
-     * Randomly selects a point on a random face.
+     * Randomly selects a point on a random face of one of the stored meshes.
+     * The point is generated using barycentric coordinates to ensure uniform
+     * distribution within the selected triangle.
      *
-     * @param store
-     *        storage for the coordinates of the selected point
+     * @param store a {@link Vector3f} object where the coordinates of the
+     *              selected point will be stored.
      */
     @Override
     public void getRandomPoint(Vector3f store) {
         int meshIndex = FastMath.nextRandomInt(0, vertices.size() - 1);
+        List<Vector3f> currVertices = vertices.get(meshIndex);
+        int numVertices = currVertices.size();
+
         // the index of the first vertex of a face (must be dividable by 3)
-        int vertIndex = FastMath.nextRandomInt(0, vertices.get(meshIndex).size() / 3 - 1) * 3;
-        // put the point somewhere between the first and the second vertex of a face
-        float moveFactor = FastMath.nextRandomFloat();
-        store.set(Vector3f.ZERO);
-        store.addLocal(vertices.get(meshIndex).get(vertIndex));
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 1).x - vertices.get(meshIndex).get(vertIndex).x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).y - vertices.get(meshIndex).get(vertIndex).y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).z - vertices.get(meshIndex).get(vertIndex).z) * moveFactor);
-        // move the result towards the last face vertex
-        moveFactor = FastMath.nextRandomFloat();
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 2).x - store.x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).y - store.y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).z - store.z) * moveFactor);
+        int faceIndex = FastMath.nextRandomInt(0, numVertices / 3 - 1);
+        int vertIndex = faceIndex * 3;
+
+        // Generate the random point on the triangle
+        generateRandomPointOnTriangle(currVertices, vertIndex, store);
     }
 
     /**
-     * Randomly selects a point on a random face.
-     * The {@code normal} argument is set to the normal of the selected face.
+     * Randomly selects a point on a random face of one of the stored meshes,
+     * and also sets the normal of that selected face.
+     * The point is generated using barycentric coordinates for uniform distribution.
      *
-     * @param store
-     *        storage for the coordinates of the selected point
-     * @param normal
-     *        storage for the normal of the selected face
+     * @param store  a {@link Vector3f} object where the coordinates of the
+     *               selected point will be stored.
+     * @param normal a {@link Vector3f} object where the normal of the
+     *               selected face will be stored.
      */
     @Override
     public void getRandomPointAndNormal(Vector3f store, Vector3f normal) {
         int meshIndex = FastMath.nextRandomInt(0, vertices.size() - 1);
+        List<Vector3f> currVertices = vertices.get(meshIndex);
+        int numVertices = currVertices.size();
+
         // the index of the first vertex of a face (must be dividable by 3)
-        int faceIndex = FastMath.nextRandomInt(0, vertices.get(meshIndex).size() / 3 - 1);
+        int faceIndex = FastMath.nextRandomInt(0, numVertices / 3 - 1);
         int vertIndex = faceIndex * 3;
-        // put the point somewhere between the first and the second vertex of a face
-        float moveFactor = FastMath.nextRandomFloat();
-        store.set(Vector3f.ZERO);
-        store.addLocal(vertices.get(meshIndex).get(vertIndex));
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 1).x - vertices.get(meshIndex).get(vertIndex).x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).y - vertices.get(meshIndex).get(vertIndex).y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 1).z - vertices.get(meshIndex).get(vertIndex).z) * moveFactor);
-        // move the result towards the last face vertex
-        moveFactor = FastMath.nextRandomFloat();
-        store.addLocal((vertices.get(meshIndex).get(vertIndex + 2).x - store.x) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).y - store.y) * moveFactor, (vertices.get(meshIndex).get(vertIndex + 2).z - store.z) * moveFactor);
+
+        // Generate the random point on the triangle
+        generateRandomPointOnTriangle(currVertices, vertIndex, store);
+        // Set the normal from the pre-computed normals list for the selected face
         normal.set(normals.get(meshIndex).get(faceIndex));
     }
+
+    /**
+     * Internal method to generate a random point within a specific triangle
+     * using barycentric coordinates.
+     *
+     * @param currVertices The list of vertices for the current mesh.
+     * @param vertIndex    The starting index of the triangle's first vertex
+     *                     within the {@code currVertices} list.
+     * @param store        A {@link Vector3f} object where the calculated point will be stored.
+     */
+    private void generateRandomPointOnTriangle(List<Vector3f> currVertices, int vertIndex, Vector3f store) {
+
+        Vector3f v1 = currVertices.get(vertIndex);
+        Vector3f v2 = currVertices.get(vertIndex + 1);
+        Vector3f v3 = currVertices.get(vertIndex + 2);
+
+        // Generate random barycentric coordinates
+        float u = FastMath.nextRandomFloat();
+        float v = FastMath.nextRandomFloat();
+
+        if ((u + v) > 1) {
+            u = 1 - u;
+            v = 1 - v;
+        }
+
+        // P = v1 + u * (v2 - v1) + v * (v3 - v1)
+        store.x = v1.x + u * (v2.x - v1.x) + v * (v3.x - v1.x);
+        store.y = v1.y + u * (v2.y - v1.y) + v * (v3.y - v1.y);
+        store.z = v1.z + u * (v2.z - v1.z) + v * (v3.z - v1.z);
+    }
+
 }

+ 167 - 0
jme3-core/src/main/java/com/jme3/environment/util/Circle.java

@@ -0,0 +1,167 @@
+ /*
+  * Copyright (c) 2009-2025 jMonkeyEngine
+  * All rights reserved.
+  *
+  * Redistribution and use in source and binary forms, with or without
+  * modification, are permitted provided that the following conditions are
+  * met:
+  *
+  * * Redistributions of source code must retain the above copyright
+  *   notice, this list of conditions and the following disclaimer.
+  *
+  * * Redistributions in binary form must reproduce the above copyright
+  *   notice, this list of conditions and the following disclaimer in the
+  *   documentation and/or other materials provided with the distribution.
+  *
+  * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+  *   may be used to endorse or promote products derived from this software
+  *   without specific prior written permission.
+  *
+  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+  * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+  * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+  * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+  * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+  * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+  * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+  * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+  * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+  */
+package com.jme3.environment.util;
+
+import com.jme3.asset.AssetManager;
+import com.jme3.export.InputCapsule;
+import com.jme3.export.JmeExporter;
+import com.jme3.export.JmeImporter;
+import com.jme3.export.OutputCapsule;
+import com.jme3.material.Material;
+import com.jme3.material.RenderState;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.renderer.queue.RenderQueue;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.VertexBuffer.Type;
+import com.jme3.util.BufferUtils;
+
+import java.io.IOException;
+import java.nio.FloatBuffer;
+import java.nio.ShortBuffer;
+
+/**
+ * <p>A `Circle` is a 2D mesh representing a circular outline (wireframe).
+ * It's defined by a specified number of radial samples, which determine its smoothness.</p>
+ *
+ * <p>The circle is centered at (0,0,0) in its local coordinate space and has a radius of 1.0.</p>
+ *
+ * @author capdevon
+ */
+public class Circle extends Mesh {
+
+    // The number of segments used to approximate the circle.
+    protected int radialSamples = 256;
+
+    /**
+     * Creates a new `Circle` mesh.
+     */
+    public Circle() {
+        setGeometryData();
+        setIndexData();
+    }
+
+    /**
+     * Initializes the vertex buffers for the circle mesh.
+     */
+    private void setGeometryData() {
+
+        int numVertices = radialSamples + 1;
+
+        FloatBuffer posBuf = BufferUtils.createVector3Buffer(numVertices);
+        FloatBuffer colBuf = BufferUtils.createFloatBuffer(numVertices * 4);
+        FloatBuffer texBuf = BufferUtils.createVector2Buffer(numVertices);
+
+        // --- Generate Geometry Data ---
+        float angleStep = FastMath.TWO_PI / radialSamples;
+
+        // Define the color for the entire circle.
+        ColorRGBA color = ColorRGBA.Orange;
+
+        // Populate the position, color, and texture coordinate buffers.
+        for (int i = 0; i < numVertices; i++) {
+            float angle = angleStep * i;
+            float cos = FastMath.cos(angle);
+            float sin = FastMath.sin(angle);
+
+            posBuf.put(cos).put(sin).put(0);
+            colBuf.put(color.r).put(color.g).put(color.b).put(color.a);
+            texBuf.put(i % 2f).put(i % 2f);
+        }
+
+        setBuffer(Type.Position, 3, posBuf);
+        setBuffer(Type.Color, 4, colBuf);
+        setBuffer(Type.TexCoord, 2, texBuf);
+
+        setMode(Mode.Lines);
+        updateBound();
+        setStatic();
+    }
+
+    /**
+     * Initializes the index buffer for the circle mesh.
+     */
+    private void setIndexData() {
+        // allocate connectivity
+        int numIndices = radialSamples * 2;
+
+        ShortBuffer idxBuf = BufferUtils.createShortBuffer(numIndices);
+        setBuffer(Type.Index, 2, idxBuf);
+
+        // --- Generate Index Data ---
+        for (int i = 0; i < radialSamples; i++) {
+            idxBuf.put((short) i);         // Start of segment
+            idxBuf.put((short) (i + 1));   // End of segment
+        }
+    }
+
+    /**
+     * Creates a {@link Geometry} object representing a dashed wireframe circle.
+     *
+     * @param assetManager The application's AssetManager to load materials.
+     * @param name         The desired name for the Geometry.
+     * @return A new Geometry instance with a `Circle` mesh.
+     */
+    public static Geometry createShape(AssetManager assetManager, String name) {
+        Circle mesh = new Circle();
+        Geometry geom = new Geometry(name, mesh);
+        geom.setQueueBucket(RenderQueue.Bucket.Transparent);
+
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Dashed.j3md");
+        mat.getAdditionalRenderState().setWireframe(true);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        mat.getAdditionalRenderState().setDepthWrite(false);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        mat.getAdditionalRenderState().setLineWidth(2f);
+        mat.setColor("Color", ColorRGBA.Orange);
+        mat.setFloat("DashSize", 0.5f);
+        geom.setMaterial(mat);
+
+        return geom;
+    }
+
+    @Override
+    public void write(JmeExporter ex) throws IOException {
+        super.write(ex);
+        OutputCapsule oc = ex.getCapsule(this);
+        oc.write(radialSamples, "radialSamples", 256);
+    }
+
+    @Override
+    public void read(JmeImporter im) throws IOException {
+        super.read(im);
+        InputCapsule ic = im.getCapsule(this);
+        radialSamples = ic.readInt("radialSamples", 256);
+    }
+
+}

+ 359 - 86
jme3-core/src/main/java/com/jme3/environment/util/LightsDebugState.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -33,154 +33,427 @@ package com.jme3.environment.util;
 
 import com.jme3.app.Application;
 import com.jme3.app.state.BaseAppState;
-import com.jme3.light.*;
+import com.jme3.asset.AssetManager;
+import com.jme3.light.DirectionalLight;
+import com.jme3.light.Light;
+import com.jme3.light.LightProbe;
+import com.jme3.light.PointLight;
+import com.jme3.light.SpotLight;
 import com.jme3.material.Material;
+import com.jme3.material.RenderState;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.FastMath;
+import com.jme3.math.Quaternion;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.RenderManager;
+import com.jme3.renderer.ViewPort;
+import com.jme3.renderer.queue.RenderQueue;
 import com.jme3.scene.Geometry;
 import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
+import com.jme3.scene.control.BillboardControl;
+import com.jme3.scene.debug.Arrow;
+import com.jme3.scene.shape.Quad;
 import com.jme3.scene.shape.Sphere;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
+import com.jme3.texture.Texture;
+
+import java.util.ArrayDeque;
+import java.util.Iterator;
 import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.function.Predicate;
 
 /**
- * A debug state that will display Light gizmos on screen.
- * Still a wip and for now it only displays light probes.
+ * A debug state that visualizes different types of lights in the scene with gizmos.
+ * This state is useful for debugging light positions, ranges, and other properties.
  *
  * @author nehon
+ * @author capdevon
  */
 public class LightsDebugState extends BaseAppState {
 
-    private Node debugNode;
-    private final Map<LightProbe, Node> probeMapping = new HashMap<>();
-    private final List<LightProbe> garbage = new ArrayList<>();
-    private Geometry debugGeom;
-    private Geometry debugBounds;
+    private static final String PROBE_GEOMETRY_NAME = "DebugProbeGeometry";
+    private static final String PROBE_BOUNDS_NAME = "DebugProbeBounds";
+    private static final String SPOT_LIGHT_INNER_RADIUS_NAME = "SpotLightInnerRadius";
+    private static final String SPOT_LIGHT_OUTER_RADIUS_NAME = "SpotLightOuterRadius";
+    private static final String SPOT_LIGHT_RADIUS_NAME = "RadiusNode";
+    private static final String POINT_LIGHT_RADIUS_NAME = "PointLightRadius";
+    private static final String LIGHT_DIR_ARROW_NAME = "LightDirection";
+
+    private final Map<Light, Spatial> lightGizmoMap = new WeakHashMap<>();
+    private final ArrayDeque<Light> lightDeque = new ArrayDeque<>();
+    private Predicate<Light> lightFilter = x -> true; // Identity Function
+
+    private ViewPort viewPort;
+    private AssetManager assetManager;
     private Material debugMaterial;
-    private float probeScale = 1.0f;
-    private Spatial scene = null;
-    private final List<LightProbe> probes = new ArrayList<>();
+    private Node debugNode;
+    private Spatial scene; // The scene whose lights will be debugged
+
+    private boolean showOnTop = true;
+    private float lightProbeScale = 1.0f;
+    private final ColorRGBA debugColor = ColorRGBA.DarkGray;
+    private final Quaternion tempRotation = new Quaternion();
 
     @Override
     protected void initialize(Application app) {
-        debugNode = new Node("Environment debug Node");
-        Sphere s = new Sphere(16, 16, 0.15f);
-        debugGeom = new Geometry("debugEnvProbe", s);
-        debugMaterial = new Material(app.getAssetManager(), "Common/MatDefs/Misc/reflect.j3md");
-        debugGeom.setMaterial(debugMaterial);
-        debugBounds = BoundingSphereDebug.createDebugSphere(app.getAssetManager());
+
+        viewPort = app.getRenderManager().createMainView("LightsDebugView", app.getCamera());
+        viewPort.setClearFlags(false, showOnTop, true);
+
+        assetManager = app.getAssetManager();
+        debugNode = new Node("LightsDebugNode");
+
+        debugMaterial = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        debugMaterial.setColor("Color", debugColor);
+        debugMaterial.getAdditionalRenderState().setWireframe(true);
+
         if (scene == null) {
             scene = app.getViewPort().getScenes().get(0);
         }
     }
 
+    private Spatial createBulb() {
+        Quad q = new Quad(0.5f, 0.5f);
+        Geometry lightBulb = new Geometry("LightBulb", q);
+        lightBulb.move(-q.getHeight() / 2f, -q.getWidth() / 2f, 0);
+
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
+        Texture tex = assetManager.loadTexture("Common/Textures/lightbulb32.png");
+        mat.setTexture("ColorMap", tex);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        lightBulb.setMaterial(mat);
+        lightBulb.setQueueBucket(RenderQueue.Bucket.Transparent);
+
+        Node billboard = new Node("Billboard");
+        billboard.addControl(new BillboardControl());
+        billboard.attachChild(lightBulb);
+
+        return billboard;
+    }
+
+    private Geometry createRadiusShape(String name, float dashSize) {
+        Geometry radius = Circle.createShape(assetManager, name);
+        Material mat = radius.getMaterial();
+        mat.setColor("Color", debugColor);
+        mat.setFloat("DashSize", dashSize);
+        return radius;
+    }
+
+    private Spatial createPointGizmo() {
+        Node gizmo = new Node("PointLightNode");
+        gizmo.attachChild(createBulb());
+
+        Geometry radius = new Geometry(POINT_LIGHT_RADIUS_NAME, new BoundingSphereDebug());
+        radius.setMaterial(debugMaterial);
+        gizmo.attachChild(radius);
+
+        return gizmo;
+    }
+
+    private Spatial createDirectionalGizmo() {
+        Node gizmo = new Node("DirectionalLightNode");
+        gizmo.move(0, 5, 0);
+        gizmo.attachChild(createBulb());
+
+        Geometry arrow = new Geometry(LIGHT_DIR_ARROW_NAME, new Arrow(Vector3f.UNIT_Z.mult(5f)));
+        arrow.setMaterial(debugMaterial);
+        gizmo.attachChild(arrow);
+
+        return gizmo;
+    }
+
+    private Spatial createSpotGizmo() {
+        Node gizmo = new Node("SpotLightNode");
+        gizmo.attachChild(createBulb());
+
+        Node radiusNode = new Node(SPOT_LIGHT_RADIUS_NAME);
+        gizmo.attachChild(radiusNode);
+
+        Geometry inRadius = createRadiusShape(SPOT_LIGHT_INNER_RADIUS_NAME, 0.725f);
+        radiusNode.attachChild(inRadius);
+
+        Geometry outRadius = createRadiusShape(SPOT_LIGHT_OUTER_RADIUS_NAME, 0.325f);
+        radiusNode.attachChild(outRadius);
+
+        Geometry arrow = new Geometry(LIGHT_DIR_ARROW_NAME, new Arrow(Vector3f.UNIT_Z));
+        arrow.setMaterial(debugMaterial);
+        gizmo.attachChild(arrow);
+
+        return gizmo;
+    }
+
+    private Spatial createLightProbeGizmo() {
+        Node gizmo = new Node("LightProbeNode");
+
+        Sphere sphere = new Sphere(32, 32, lightProbeScale);
+        Geometry probeGeom = new Geometry(PROBE_GEOMETRY_NAME, sphere);
+        Material mat = new Material(assetManager, "Common/MatDefs/Misc/reflect.j3md");
+        probeGeom.setMaterial(mat);
+        gizmo.attachChild(probeGeom);
+
+        Geometry probeBounds = BoundingSphereDebug.createDebugSphere(assetManager);
+        probeBounds.setName(PROBE_BOUNDS_NAME);
+        gizmo.attachChild(probeBounds);
+
+        return gizmo;
+    }
+
+    /**
+     * Updates the light gizmos based on the current state of lights in the scene.
+     * This method is called every frame when the state is enabled.
+     *
+     * @param tpf The time per frame.
+     */
     @Override
     public void update(float tpf) {
-        if (!isEnabled()) {
-            return;
-        }
-        updateLights(scene);
+        updateLightGizmos(scene);
         debugNode.updateLogicalState(tpf);
+        cleanUpRemovedLights();
+    }
+
+    /**
+     * Renders the debug gizmos onto the screen.
+     *
+     * @param rm The render manager.
+     */
+    @Override
+    public void render(RenderManager rm) {
         debugNode.updateGeometricState();
-        cleanProbes();
-    }
-
-    public void updateLights(Spatial scene) {
-        for (Light light : scene.getWorldLightList()) {
-            switch (light.getType()) {
-
-                case Probe:
-                    LightProbe probe = (LightProbe) light;
-                    probes.add(probe);
-                    Node n = probeMapping.get(probe);
-                    if (n == null) {
-                        n = new Node("DebugProbe");
-                        n.attachChild(debugGeom.clone(true));
-                        n.attachChild(debugBounds.clone(false));
-                        debugNode.attachChild(n);
-                        probeMapping.put(probe, n);
-                    }
-                    Geometry probeGeom = ((Geometry) n.getChild(0));
-                    Material m = probeGeom.getMaterial();
-                    probeGeom.setLocalScale(probeScale);
-                    if (probe.isReady()) {
-                        m.setTexture("CubeMap", probe.getPrefilteredEnvMap());
-                    }
-                    n.setLocalTranslation(probe.getPosition());
-                    n.getChild(1).setLocalScale(probe.getArea().getRadius());
-                    break;
-                default:
-                    break;
+    }
+
+    /**
+     * Recursively traverses the scene graph to find and update light gizmos.
+     * New gizmos are created for new lights, and existing gizmos are updated.
+     *
+     * @param spatial The current spatial to process for lights.
+     */
+    private void updateLightGizmos(Spatial spatial) {
+        // Add or update gizmos for lights attached to the current spatial
+        for (Light light : spatial.getLocalLightList()) {
+            if (!lightFilter.test(light)) {
+                continue;
+            }
+
+            lightDeque.add(light);
+            Spatial gizmo = lightGizmoMap.get(light);
+
+            if (gizmo == null) {
+                gizmo = createLightGizmo(light);
+                if (gizmo != null) {
+                    debugNode.attachChild(gizmo);
+                    lightGizmoMap.put(light, gizmo);
+                    updateGizmoProperties(light, gizmo); // Set initial properties
+                }
+            } else {
+                updateGizmoProperties(light, gizmo);
             }
         }
-        if (scene instanceof Node) {
-            Node n = (Node)scene;
-            for (Spatial spatial : n.getChildren()) {
-                updateLights(spatial);
+
+        // Recursively call for children if it's a Node
+        if (spatial instanceof Node) {
+            Node node = (Node) spatial;
+            for (Spatial child : node.getChildren()) {
+                updateLightGizmos(child);
             }
         }
     }
 
     /**
-     * Set the scenes for which to render light gizmos.
+     * Creates a new gizmo spatial for a given light based on its type.
      *
-     * @param scene the root of the desired scene (alias created)
+     * @param light The light for which to create a gizmo.
+     * @return A spatial representing the gizmo, or null if the light type is not supported.
      */
-    public void setScene(Spatial scene) {
-        this.scene = scene;
+    private Spatial createLightGizmo(Light light) {
+        switch (light.getType()) {
+            case Probe:
+                return createLightProbeGizmo();
+            case Point:
+                return createPointGizmo();
+            case Directional:
+                return createDirectionalGizmo();
+            case Spot:
+                return createSpotGizmo();
+            default:
+                // Unsupported light type
+                return null;
+        }
     }
 
-    private void cleanProbes() {
-        if (probes.size() != probeMapping.size()) {
-            for (LightProbe probe : probeMapping.keySet()) {
-                if (!probes.contains(probe)) {
-                    garbage.add(probe);
+    /**
+     * Updates the visual properties and position of a light gizmo based on its corresponding light.
+     *
+     * @param light The light whose properties are used for updating the gizmo.
+     * @param gizmo The spatial representing the light gizmo.
+     */
+    private void updateGizmoProperties(Light light, Spatial gizmo) {
+        Node lightNode = (Node) gizmo;
+
+        switch (light.getType()) {
+            case Probe:
+                LightProbe probe = (LightProbe) light;
+                Geometry probeGeom = (Geometry) lightNode.getChild(PROBE_GEOMETRY_NAME);
+                Geometry probeBounds = (Geometry) lightNode.getChild(PROBE_BOUNDS_NAME);
+
+                // Update texture if probe is ready
+                if (probe.isReady()) {
+                    Material mat = probeGeom.getMaterial();
+                    if (mat.getTextureParam("CubeMap") == null) {
+                        mat.setTexture("CubeMap", probe.getPrefilteredEnvMap());
+                    }
                 }
-            }
-            for (LightProbe probe : garbage) {
-                probeMapping.remove(probe);
-            }
-            garbage.clear();
-            probes.clear();
+                probeGeom.setLocalScale(lightProbeScale);
+                probeBounds.setLocalScale(probe.getArea().getRadius());
+                gizmo.setLocalTranslation(probe.getPosition());
+                break;
+
+            case Point:
+                PointLight pl = (PointLight) light;
+                Geometry radius = (Geometry) lightNode.getChild(POINT_LIGHT_RADIUS_NAME);
+                radius.setLocalScale(pl.getRadius());
+                gizmo.setLocalTranslation(pl.getPosition());
+                break;
+
+            case Spot:
+                SpotLight sl = (SpotLight) light;
+                gizmo.setLocalTranslation(sl.getPosition());
+
+                tempRotation.lookAt(sl.getDirection(), Vector3f.UNIT_Y);
+                gizmo.setLocalRotation(tempRotation);
+
+                float spotRange = sl.getSpotRange();
+                float innerAngle = sl.getSpotInnerAngle();
+                float outerAngle = sl.getSpotOuterAngle();
+                float innerRadius = spotRange * FastMath.tan(innerAngle);
+                float outerRadius = spotRange * FastMath.tan(outerAngle);
+
+                lightNode.getChild(SPOT_LIGHT_INNER_RADIUS_NAME).setLocalScale(innerRadius);
+                lightNode.getChild(SPOT_LIGHT_OUTER_RADIUS_NAME).setLocalScale(outerRadius);
+                lightNode.getChild(SPOT_LIGHT_RADIUS_NAME).setLocalTranslation(0, 0, spotRange);
+                lightNode.getChild(LIGHT_DIR_ARROW_NAME).setLocalScale(spotRange);
+                break;
+
+            case Directional:
+                DirectionalLight dl = (DirectionalLight) light;
+                tempRotation.lookAt(dl.getDirection(), Vector3f.UNIT_Y);
+                gizmo.setLocalRotation(tempRotation);
+                break;
+
+            default:
+                // Unsupported light type
+                break;
         }
     }
 
-    @Override
-    public void render(RenderManager rm) {
-        if (!isEnabled()) {
-            return;
+    /**
+     * Cleans up gizmos for lights that have been removed from the scene.
+     */
+    private void cleanUpRemovedLights() {
+
+        Iterator<Map.Entry<Light, Spatial>> iterator = lightGizmoMap.entrySet().iterator();
+
+        while (iterator.hasNext()) {
+            Map.Entry<Light, Spatial> entry = iterator.next();
+            Light light = entry.getKey();
+
+            if (!lightDeque.contains(light)) {
+                Spatial gizmo = entry.getValue();
+                gizmo.removeFromParent();
+                iterator.remove();
+            }
         }
-        rm.renderScene(debugNode, getApplication().getViewPort());
+
+        lightDeque.clear();
     }
 
     /**
-     * returns the scale of the probe's debug sphere
-     * @return the scale factor
+     * Sets the scene for which to render light gizmos.
+     * If no scene is set, it defaults to the first scene in the viewport.
+     *
+     * @param scene The root of the desired scene.
+     */
+    public void setScene(Spatial scene) {
+        this.scene = scene;
+        // Clear existing gizmos when the scene changes to avoid displaying gizmos from the old scene
+        debugNode.detachAllChildren();
+        lightGizmoMap.clear();
+        lightDeque.clear();
+    }
+
+    /**
+     * Returns the current scale of the light probe's debug sphere.
+     *
+     * @return The scale factor.
+     */
+    public float getLightProbeScale() {
+        return lightProbeScale;
+    }
+
+    /**
+     * Sets the scale of the light probe's debug sphere.
+     *
+     * @param scale The scale factor (default is 1.0).
+     */
+    public void setLightProbeScale(float scale) {
+        this.lightProbeScale = scale;
+    }
+
+    /**
+     * Checks if the light debug gizmos are set to always
+     * render on top of other scene geometry.
+     *
+     * @return true if gizmos always render on top, false otherwise.
+     */
+    public boolean isShowOnTop() {
+        return showOnTop;
+    }
+
+    /**
+     * Sets whether light debug gizmos should always
+     * render on top of other scene geometry.
+     *
+     * @param showOnTop true to always show gizmos on top, false to respect depth.
      */
-    public float getProbeScale() {
-        return probeScale;
+    public void setShowOnTop(boolean showOnTop) {
+        this.showOnTop = showOnTop;
+        if (viewPort != null) {
+            viewPort.setClearDepth(showOnTop);
+        }
     }
 
     /**
-     * sets the scale of the probe's debug sphere
+     * Sets a filter to control which lights are displayed by the debug state.
+     * By default, no filter is applied, meaning all lights are displayed.
      *
-     * @param probeScale the scale factor (default=1)
+     * @param lightFilter A {@link Predicate} that tests a {@link Light} object.
      */
-    public void setProbeScale(float probeScale) {
-        this.probeScale = probeScale;
+    public void setLightFilter(Predicate<Light> lightFilter) {
+        this.lightFilter = lightFilter;
     }
 
+    /**
+     * Cleans up resources when the app state is detached.
+     *
+     * @param app The application instance.
+     */
     @Override
     protected void cleanup(Application app) {
+        debugNode.detachAllChildren();
+        lightGizmoMap.clear();
+        lightDeque.clear();
+        debugMaterial = null;
+        app.getRenderManager().removeMainView(viewPort);
     }
 
     @Override
     protected void onEnable() {
+        viewPort.attachScene(debugNode);
     }
 
     @Override
     protected void onDisable() {
+        viewPort.detachScene(debugNode);
     }
+
 }

+ 10 - 1
jme3-core/src/main/java/com/jme3/light/AmbientLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -83,4 +83,13 @@ public class AmbientLight extends Light {
         return Type.Ambient;
     }
 
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
+
 }

+ 11 - 6
jme3-core/src/main/java/com/jme3/light/DirectionalLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015-2016 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -132,11 +132,6 @@ public class DirectionalLight extends Light {
         return Type.Directional;
     }
 
-    @Override
-    public String toString() {
-        return getClass().getSimpleName() + "[name=" + name + ", direction=" + direction + ", color=" + color + ", enabled=" + enabled + "]";
-    }
-
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
@@ -157,4 +152,14 @@ public class DirectionalLight extends Light {
         l.direction = direction.clone();
         return l;
     }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", direction=" + direction
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
 }

+ 13 - 13
jme3-core/src/main/java/com/jme3/light/LightProbe.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -48,7 +48,7 @@ import java.util.logging.Logger;
 /**
  * A LightProbe is not exactly a light. It holds environment map information used for Image Based Lighting.
  * This is used for indirect lighting in the Physically Based Rendering pipeline.
- *
+ * <p>
  * A light probe has a position in world space. This is the position from where the Environment Map are rendered.
  * There are two environment data structure  held by the LightProbe :
  * - The irradiance spherical harmonics factors (used for indirect diffuse lighting in the PBR pipeline).
@@ -57,9 +57,9 @@ import java.util.logging.Logger;
  * To compute them see
  * {@link com.jme3.environment.LightProbeFactory#makeProbe(com.jme3.environment.EnvironmentCamera, com.jme3.scene.Spatial)}
  * and {@link EnvironmentCamera}.
- *
+ * <p>
  * The light probe has an area of effect centered on its position. It can have a Spherical area or an Oriented Box area
- *
+ * <p>
  * A LightProbe will only be taken into account when it's marked as ready and enabled.
  * A light probe is ready when it has valid environment map data set.
  * Note that you should never call setReady yourself.
@@ -71,7 +71,8 @@ import java.util.logging.Logger;
 public class LightProbe extends Light implements Savable {
 
     private static final Logger logger = Logger.getLogger(LightProbe.class.getName());
-    public static final Matrix4f FALLBACK_MATRIX = new Matrix4f(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1);
+    public static final Matrix4f FALLBACK_MATRIX = new Matrix4f(
+            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1);
 
     private Vector3f[] shCoefficients;
     private TextureCubeMap prefilteredEnvMap;
@@ -149,16 +150,12 @@ public class LightProbe extends Light implements Savable {
      * @return the pre-existing matrix
      */
     public Matrix4f getUniformMatrix(){
-
         Matrix4f mat = area.getUniformMatrix();
-
         // setting the (sp) entry of the matrix
         mat.m33 = nbMipMaps + 1f / area.getRadius();
-
         return mat;
     }
 
-
     @Override
     public void write(JmeExporter ex) throws IOException {
         super.write(ex);
@@ -180,7 +177,7 @@ public class LightProbe extends Light implements Savable {
         position = (Vector3f) ic.readSavable("position", null);
         area = (ProbeArea)ic.readSavable("area", null);
         if(area == null) {
-            // retro compat
+            // retro compatibility
             BoundingSphere bounds = (BoundingSphere) ic.readSavable("bounds", new BoundingSphere(1.0f, Vector3f.ZERO));
             area = new SphereProbeArea(bounds.getCenter(), bounds.getRadius());
         }
@@ -200,7 +197,6 @@ public class LightProbe extends Light implements Savable {
         }
     }
 
-
     /**
      * returns the bounding volume of this LightProbe
      * @return a bounding volume.
@@ -318,8 +314,12 @@ public class LightProbe extends Light implements Savable {
 
     @Override
     public String toString() {
-        return "Light Probe : " + name + " at " + position + " / " + area;
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", position=" + position
+                + ", area=" + area
+                + ", enabled=" + enabled
+                + "]";
     }
 
-
 }

+ 12 - 1
jme3-core/src/main/java/com/jme3/light/PointLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012, 2015-2016, 2018 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -247,4 +247,15 @@ public class PointLight extends Light {
         p.position = position.clone();
         return p;
     }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", position=" + position
+                + ", radius=" + radius
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
 }

+ 16 - 5
jme3-core/src/main/java/com/jme3/light/SpotLight.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -118,8 +118,7 @@ public class SpotLight extends Light {
         setPosition(position);
         setDirection(direction);
     }
-    
-    
+
     /**
      * Creates a SpotLight at the given position, with the given direction,
      * the given range and the given color.
@@ -160,7 +159,6 @@ public class SpotLight extends Light {
         setDirection(direction);
         setSpotRange(range);
     }  
-    
 
     private void computeAngleParameters() {
         float innerCos = FastMath.cos(spotInnerAngle);
@@ -458,5 +456,18 @@ public class SpotLight extends Light {
         s.position = position.clone();
         return s;
     }
-}
 
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+                + "[name=" + name
+                + ", direction=" + direction
+                + ", position=" + position
+                + ", range=" + spotRange
+                + ", innerAngle=" + spotInnerAngle
+                + ", outerAngle=" + spotOuterAngle
+                + ", color=" + color
+                + ", enabled=" + enabled
+                + "]";
+    }
+}

+ 6 - 6
jme3-core/src/main/java/com/jme3/renderer/RenderManager.java

@@ -95,8 +95,8 @@ public class RenderManager {
     private final ArrayList<ViewPort> viewPorts = new ArrayList<>();
     private final ArrayList<ViewPort> postViewPorts = new ArrayList<>();
     private final HashMap<Class, PipelineContext> contexts = new HashMap<>();
-    private final LinkedList<PipelineContext> usedContexts = new LinkedList<>();
-    private final LinkedList<RenderPipeline> usedPipelines = new LinkedList<>();
+    private final ArrayList<PipelineContext> usedContexts = new ArrayList<>();
+    private final ArrayList<RenderPipeline> usedPipelines = new ArrayList<>();
     private RenderPipeline defaultPipeline = new ForwardPipeline();
     private Camera prevCam = null;
     private Material forcedMaterial = null;
@@ -1367,11 +1367,11 @@ public class RenderManager {
         }
         
         // cleanup for used render pipelines and pipeline contexts only
-        for (PipelineContext c : usedContexts) {
-            c.endContextRenderFrame(this);
+        for (int i = 0; i < usedContexts.size(); i++) {
+            usedContexts.get(i).endContextRenderFrame(this);
         }
-        for (RenderPipeline p : usedPipelines) {
-            p.endRenderFrame(this);
+        for (int i = 0; i < usedPipelines.size(); i++) {
+            usedPipelines.get(i).endRenderFrame(this);
         }
         usedContexts.clear();
         usedPipelines.clear();

+ 340 - 112
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugAppState.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,59 +31,115 @@
  */
 package com.jme3.scene.debug.custom;
 
-import com.jme3.anim.*;
+import com.jme3.anim.Armature;
+import com.jme3.anim.Joint;
+import com.jme3.anim.SkinningControl;
 import com.jme3.app.Application;
 import com.jme3.app.state.BaseAppState;
+import com.jme3.asset.AssetManager;
 import com.jme3.collision.CollisionResults;
+import com.jme3.input.InputManager;
 import com.jme3.input.KeyInput;
 import com.jme3.input.MouseInput;
-import com.jme3.input.controls.*;
-import com.jme3.light.DirectionalLight;
-import com.jme3.math.*;
+import com.jme3.input.controls.ActionListener;
+import com.jme3.input.controls.KeyTrigger;
+import com.jme3.input.controls.MouseButtonTrigger;
+import com.jme3.math.Ray;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.Camera;
+import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.ViewPort;
-import com.jme3.scene.*;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Node;
+import com.jme3.scene.SceneGraphVisitorAdapter;
+import com.jme3.scene.Spatial;
+import com.jme3.scene.control.AbstractControl;
+import com.jme3.util.TempVars;
 
-import java.util.*;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 /**
+ * A debug application state for visualizing and interacting with JME3 armatures (skeletons).
+ * This state allows users to see the joints of an armature, select individual joints
+ * by clicking on them, and view their local and model transforms.
+ * It also provides a toggle to display non-deforming joints.
+ * <p>
+ * This debug state operates on its own `ViewPort` and `debugNode` to prevent
+ * interference with the main scene's rendering.
+ *
  * @author Nehon
+ * @author capdevon
  */
 public class ArmatureDebugAppState extends BaseAppState {
 
+    private static final Logger logger = Logger.getLogger(ArmatureDebugAppState.class.getName());
+
+    private static final String PICK_JOINT = "ArmatureDebugAppState_PickJoint";
+    private static final String TOGGLE_JOINTS = "ArmatureDebugAppState_DisplayAllJoints";
+
+    /**
+     * The maximum delay for a mouse click to be registered as a single click.
+     */
     public static final float CLICK_MAX_DELAY = 0.2f;
-    private Node debugNode = new Node("debugNode");
-    private Map<Armature, ArmatureDebugger> armatures = new HashMap<>();
-    private Map<Armature, Joint> selectedBones = new HashMap<>();
-    private Application app;
-    private boolean displayAllJoints = false;
+
+    private Node debugNode = new Node("ArmaturesDebugNode");
+    private final Map<Armature, ArmatureDebugger> armatures = new HashMap<>();
+    private final List<Consumer<Joint>> selectionListeners = new ArrayList<>();
+    private boolean displayNonDeformingJoints = false;
     private float clickDelay = -1;
-    Vector3f tmp = new Vector3f();
-    Vector3f tmp2 = new Vector3f();
-    ViewPort vp;
+    private ViewPort vp;
+    private Camera cam;
+    private InputManager inputManager;
+    private boolean showOnTop = true;
+    private boolean enableJointInfoLogging = true;
 
     @Override
     protected void initialize(Application app) {
-        vp = app.getRenderManager().createMainView("debug", app.getCamera());
+
+        inputManager = app.getInputManager();
+        cam = app.getCamera();
+
+        vp = app.getRenderManager().createMainView("ArmatureDebugView", cam);
         vp.attachScene(debugNode);
-        vp.setClearDepth(true);
-        this.app = app;
-        for (ArmatureDebugger armatureDebugger : armatures.values()) {
-            armatureDebugger.initialize(app.getAssetManager(), app.getCamera());
-        }
-        app.getInputManager().addListener(actionListener, "shoot", "toggleJoints");
-        app.getInputManager().addMapping("shoot", new MouseButtonTrigger(MouseInput.BUTTON_LEFT), new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
-        app.getInputManager().addMapping("toggleJoints", new KeyTrigger(KeyInput.KEY_F10));
+        vp.setClearDepth(showOnTop);
 
-        debugNode.addLight(new DirectionalLight(new Vector3f(-1f, -1f, -1f).normalizeLocal()));
+        for (ArmatureDebugger debugger : armatures.values()) {
+            debugger.initialize(app.getAssetManager(), cam);
+        }
 
-        debugNode.addLight(new DirectionalLight(new Vector3f(1f, 1f, 1f).normalizeLocal(), new ColorRGBA(0.5f, 0.5f, 0.5f, 1.0f)));
+        // Initially disable the viewport until the state is enabled
         vp.setEnabled(false);
+
+        registerInput();
+    }
+
+    private void registerInput() {
+        inputManager.addMapping(PICK_JOINT, new MouseButtonTrigger(MouseInput.BUTTON_LEFT), new MouseButtonTrigger(MouseInput.BUTTON_RIGHT));
+        inputManager.addMapping(TOGGLE_JOINTS, new KeyTrigger(KeyInput.KEY_F10));
+        inputManager.addListener(actionListener, PICK_JOINT, TOGGLE_JOINTS);
+    }
+
+    private void unregisterInput() {
+        inputManager.deleteMapping(PICK_JOINT);
+        inputManager.deleteMapping(TOGGLE_JOINTS);
+        inputManager.removeListener(actionListener);
     }
 
     @Override
     protected void cleanup(Application app) {
-
+        unregisterInput();
+        app.getRenderManager().removeMainView(vp);
+        // Clear maps to release references
+        armatures.clear();
+        selectionListeners.clear();
+        debugNode.detachAllChildren();
     }
 
     @Override
@@ -102,139 +158,311 @@ public class ArmatureDebugAppState extends BaseAppState {
             clickDelay += tpf;
         }
         debugNode.updateLogicalState(tpf);
-        debugNode.updateGeometricState();
+    }
 
+    @Override
+    public void render(RenderManager rm) {
+        debugNode.updateGeometricState();
     }
 
-    public ArmatureDebugger addArmatureFrom(SkinningControl skinningControl) {
-        Armature armature = skinningControl.getArmature();
-        Spatial forSpatial = skinningControl.getSpatial();
-        return addArmatureFrom(armature, forSpatial);
+    /**
+     * Adds an ArmatureDebugger for the armature associated with a given SkinningControl.
+     *
+     * @param skControl The SkinningControl whose armature needs to be debugged.
+     * @return The newly created or existing ArmatureDebugger for the given armature.
+     */
+    public ArmatureDebugger addArmatureFrom(SkinningControl skControl) {
+        return addArmatureFrom(skControl.getArmature(), skControl.getSpatial());
     }
 
-    public ArmatureDebugger addArmatureFrom(Armature armature, Spatial forSpatial) {
+    /**
+     * Adds an ArmatureDebugger for a specific Armature, associating it with a Spatial.
+     * If an ArmatureDebugger for this armature already exists, it is returned.
+     * Otherwise, a new ArmatureDebugger is created, initialized, and attached to the debug node.
+     *
+     * @param armature The Armature to debug.
+     * @param sp The Spatial associated with this armature (used for determining world transform and deforming joints).
+     * @return The newly created or existing ArmatureDebugger for the given armature.
+     */
+    public ArmatureDebugger addArmatureFrom(Armature armature, Spatial sp) {
 
-        ArmatureDebugger ad = armatures.get(armature);
-        if(ad != null){
-            return ad;
+        ArmatureDebugger debugger = armatures.get(armature);
+        if (debugger != null) {
+            return debugger;
         }
 
-        JointInfoVisitor visitor = new JointInfoVisitor(armature);
-        forSpatial.depthFirstTraversal(visitor);
+        // Use a visitor to find joints that actually deform the mesh
+        JointInfoVisitor jointVisitor = new JointInfoVisitor(armature);
+        sp.depthFirstTraversal(jointVisitor);
+
+        Spatial target = sp;
 
-        ad = new ArmatureDebugger(forSpatial.getName() + "_Armature", armature, visitor.deformingJoints);
-        ad.setLocalTransform(forSpatial.getWorldTransform());
-        if (forSpatial instanceof Node) {
+        if (sp instanceof Node) {
             List<Geometry> geoms = new ArrayList<>();
-            findGeoms((Node) forSpatial, geoms);
+            collectGeometries((Node) sp, geoms);
             if (geoms.size() == 1) {
-                ad.setLocalTransform(geoms.get(0).getWorldTransform());
+                target = geoms.get(0);
             }
         }
-        armatures.put(armature, ad);
-        debugNode.attachChild(ad);
+
+        // Create a new ArmatureDebugger
+        debugger = new ArmatureDebugger(sp.getName() + "_ArmatureDebugger", armature, jointVisitor.deformingJoints);
+        debugger.addControl(new ArmatureDebuggerLink(target));
+
+        // Store and attach the new debugger
+        armatures.put(armature, debugger);
+        debugNode.attachChild(debugger);
+
+        // If the AppState is already initialized, initialize the new ArmatureDebugger immediately
         if (isInitialized()) {
-            ad.initialize(app.getAssetManager(), app.getCamera());
+            AssetManager assetManager = getApplication().getAssetManager();
+            debugger.initialize(assetManager, cam);
         }
-        return ad;
+        return debugger;
     }
 
-    private void findGeoms(Node node, List<Geometry> geoms) {
-        for (Spatial spatial : node.getChildren()) {
-            if (spatial instanceof Geometry) {
-                geoms.add((Geometry) spatial);
-            } else if (spatial instanceof Node) {
-                findGeoms((Node) spatial, geoms);
+    /**
+     * Recursively finds all `Geometry` instances within a given `Node` and its children.
+     *
+     * @param node The starting `Node` to search from.
+     * @param geometries The list to which found `Geometry` instances will be added.
+     */
+    private void collectGeometries(Node node, List<Geometry> geometries) {
+        for (Spatial s : node.getChildren()) {
+            if (s instanceof Geometry) {
+                geometries.add((Geometry) s);
+            } else if (s instanceof Node) {
+                collectGeometries((Node) s, geometries);
             }
         }
     }
 
-    final private ActionListener actionListener = new ActionListener() {
+    /**
+     * The ActionListener implementation to handle input events.
+     * Specifically, it processes mouse clicks for joint selection and
+     * the F10 key press for toggling display of all joints.
+     */
+    private final ActionListener actionListener = new ActionListener() {
+
+        private final CollisionResults results = new CollisionResults();
+
         @Override
         public void onAction(String name, boolean isPressed, float tpf) {
-            if (name.equals("shoot") && isPressed) {
-                clickDelay = 0;
-            }
-            if (name.equals("shoot") && !isPressed && clickDelay < CLICK_MAX_DELAY) {
-                Vector2f click2d = app.getInputManager().getCursorPosition();
-                CollisionResults results = new CollisionResults();
-
-                Camera camera = app.getCamera();
-                Vector3f click3d = camera.getWorldCoordinates(click2d, 0f, tmp);
-                Vector3f dir = camera.getWorldCoordinates(click2d, 1f, tmp2)
-                        .subtractLocal(click3d)
-                        .normalizeLocal();
-                Ray ray = new Ray(click3d, dir);
-                debugNode.collideWith(ray, results);
-
-                if (results.size() == 0) {
-                    for (ArmatureDebugger ad : armatures.values()) {
-                        ad.select(null);
-                    }
-                    return;
-                }
-                
-                // The closest result is the target that the player picked:
-                Geometry target = results.getClosestCollision().getGeometry();
-                for (ArmatureDebugger ad : armatures.values()) {
-                    Joint selectedjoint = ad.select(target);
-                    if (selectedjoint != null) {
-                        selectedBones.put(ad.getArmature(), selectedjoint);
-                        System.err.println("-----------------------");
-                        System.err.println("Selected Joint : " + selectedjoint.getName() + " in armature " + ad.getName());
-                        System.err.println("Root Bone : " + (selectedjoint.getParent() == null));
-                        System.err.println("-----------------------");
-                        System.err.println("Local translation: " + selectedjoint.getLocalTranslation());
-                        System.err.println("Local rotation: " + selectedjoint.getLocalRotation());
-                        System.err.println("Local scale: " + selectedjoint.getLocalScale());
-                        System.err.println("---");
-                        System.err.println("Model translation: " + selectedjoint.getModelTransform().getTranslation());
-                        System.err.println("Model rotation: " + selectedjoint.getModelTransform().getRotation());
-                        System.err.println("Model scale: " + selectedjoint.getModelTransform().getScale());
-                        System.err.println("---");
-                        System.err.println("Bind inverse Transform: ");
-                        System.err.println(selectedjoint.getInverseModelBindMatrix());
-                        return;
+            if (name.equals(PICK_JOINT)) {
+                if (isPressed) {
+                    // Start counting click delay on mouse press
+                    clickDelay = 0;
+
+                } else if (clickDelay < CLICK_MAX_DELAY) {
+                    // Process click only if it's a quick release (not a hold)
+                    Ray ray = screenPointToRay(cam, inputManager.getCursorPosition());
+                    results.clear();
+                    debugNode.collideWith(ray, results);
+
+                    if (results.size() == 0) {
+                        // If no collision, deselect all joints in all armatures
+                        for (ArmatureDebugger ad : armatures.values()) {
+                            ad.select(null);
+                        }
+                    } else {
+                        // Get the closest geometry hit by the ray
+                        Geometry target = results.getClosestCollision().getGeometry();
+                        logger.log(Level.INFO, "Pick: {0}", target);
+
+                        for (ArmatureDebugger ad : armatures.values()) {
+                            Joint selectedjoint = ad.select(target);
+
+                            if (selectedjoint != null) {
+                                // If a joint was selected, notify it and print its properties
+                                notifySelectionListeners(selectedjoint);
+                                printJointInfo(selectedjoint, ad);
+                                break;
+                            }
+                        }
                     }
                 }
             }
-            if (name.equals("toggleJoints") && isPressed) {
-                displayAllJoints = !displayAllJoints;
+            else if (name.equals(TOGGLE_JOINTS) && isPressed) {
+                displayNonDeformingJoints = !displayNonDeformingJoints;
                 for (ArmatureDebugger ad : armatures.values()) {
-                    ad.displayNonDeformingJoint(displayAllJoints);
+                    ad.displayNonDeformingJoint(displayNonDeformingJoints);
                 }
             }
         }
+
+        private void printJointInfo(Joint selectedjoint, ArmatureDebugger ad) {
+            if (enableJointInfoLogging) {
+                System.err.println("-----------------------");
+                System.err.println("Selected Joint : " + selectedjoint.getName() + " in armature " + ad.getName());
+                System.err.println("Root Bone : " + (selectedjoint.getParent() == null));
+                System.err.println("-----------------------");
+                System.err.println("Local translation: " + selectedjoint.getLocalTranslation());
+                System.err.println("Local rotation: " + selectedjoint.getLocalRotation());
+                System.err.println("Local scale: " + selectedjoint.getLocalScale());
+                System.err.println("---");
+                System.err.println("Model translation: " + selectedjoint.getModelTransform().getTranslation());
+                System.err.println("Model rotation: " + selectedjoint.getModelTransform().getRotation());
+                System.err.println("Model scale: " + selectedjoint.getModelTransform().getScale());
+                System.err.println("---");
+                System.err.println("Bind inverse Transform: ");
+                System.err.println(selectedjoint.getInverseModelBindMatrix());
+            }
+        }
+
+        /**
+         * Creates a `Ray` from a 2D screen point (e.g., mouse cursor position).
+         *
+         * @param cam The camera to use for ray projection.
+         * @param screenPoint The 2D screen coordinates.
+         * @return A `Ray` originating from the near plane and extending into the scene.
+         */
+        private Ray screenPointToRay(Camera cam, Vector2f screenPoint) {
+            TempVars vars = TempVars.get();
+            Vector3f nearPoint = vars.vect1;
+            Vector3f farPoint = vars.vect2;
+
+            // Get the world coordinates for the near and far points
+            cam.getWorldCoordinates(screenPoint, 0, nearPoint);
+            cam.getWorldCoordinates(screenPoint, 1, farPoint);
+
+            // Calculate direction and normalize
+            Vector3f direction = farPoint.subtractLocal(nearPoint).normalizeLocal();
+            Ray ray = new Ray(nearPoint, direction);
+
+            vars.release();
+            return ray;
+        }
     };
 
-//    public Map<Skeleton, Bone> getSelectedBones() {
-//        return selectedBones;
-//    }
+    /**
+     * Notifies all registered {@code Consumer<Joint>} listeners about the selected joint.
+     *
+     * @param selectedJoint The joint that was selected.
+     */
+    private void notifySelectionListeners(Joint selectedJoint) {
+        for (Consumer<Joint> listener : selectionListeners) {
+            listener.accept(selectedJoint);
+        }
+    }
+
+    /**
+     * Adds a listener that will be notified when a joint is selected.
+     *
+     * @param listener The {@code Consumer<Joint>} listener to add.
+     */
+    public void addSelectionListener(Consumer<Joint> listener) {
+        selectionListeners.add(listener);
+    }
+
+    /**
+     * Removes a previously added selection listener.
+     *
+     * @param listener The {@code Consumer<Joint>} listener to remove.
+     */
+    public void removeSelectionListener(Consumer<Joint> listener) {
+        selectionListeners.remove(listener);
+    }
+
+    /**
+     * Clears all registered selection listeners.
+     */
+    public void clearSelectionListeners() {
+        selectionListeners.clear();
+    }
+
+    /**
+     * Checks if the armature debug gizmos are set to always
+     * render on top of other scene geometry.
+     *
+     * @return true if gizmos always render on top, false otherwise.
+     */
+    public boolean isShowOnTop() {
+        return showOnTop;
+    }
+
+    /**
+     * Sets whether armature debug gizmos should always
+     * render on top of other scene geometry.
+     *
+     * @param showOnTop true to always show gizmos on top, false to respect depth.
+     */
+    public void setShowOnTop(boolean showOnTop) {
+        this.showOnTop = showOnTop;
+        if (vp != null) {
+            vp.setClearDepth(showOnTop);
+        }
+    }
 
-    public Node getDebugNode() {
-        return debugNode;
+    /**
+     * Returns whether logging of detailed joint information to `System.err` is currently enabled.
+     *
+     * @return true if logging is enabled, false otherwise.
+     */
+    public boolean isJointInfoLoggingEnabled() {
+        return enableJointInfoLogging;
     }
 
-    public void setDebugNode(Node debugNode) {
-        this.debugNode = debugNode;
+    /**
+     * Sets whether logging of detailed joint information to `System.err` should be enabled.
+     *
+     * @param enableJointInfoLogging true to enable logging, false to disable.
+     */
+    public void setJointInfoLoggingEnabled(boolean enableJointInfoLogging) {
+        this.enableJointInfoLogging = enableJointInfoLogging;
     }
 
-    private class JointInfoVisitor extends SceneGraphVisitorAdapter {
+    /**
+     * A utility visitor class to traverse the scene graph and identify
+     * which joints in a given armature are actually deforming a mesh.
+     */
+    private static class JointInfoVisitor extends SceneGraphVisitorAdapter {
 
-        List<Joint> deformingJoints = new ArrayList<>();
-        Armature armature;
+        private final List<Joint> deformingJoints = new ArrayList<>();
+        private final Armature armature;
 
+        /**
+         * Constructs a JointInfoVisitor for a specific armature.
+         *
+         * @param armature The armature whose deforming joints are to be identified.
+         */
         public JointInfoVisitor(Armature armature) {
             this.armature = armature;
         }
 
+        /**
+         * Visits a Geometry node in the scene graph.
+         * For each Geometry, it checks all joints in the associated armature
+         * to see if they influence this mesh.
+         *
+         * @param geo The Geometry node being visited.
+         */
         @Override
-        public void visit(Geometry g) {
+        public void visit(Geometry geo) {
             for (Joint joint : armature.getJointList()) {
-                if (g.getMesh().isAnimatedByJoint(armature.getJointIndex(joint))) {
+                int index = armature.getJointIndex(joint);
+                if (geo.getMesh().isAnimatedByJoint(index)) {
                     deformingJoints.add(joint);
                 }
             }
         }
+
+    }
+
+    private static class ArmatureDebuggerLink extends AbstractControl {
+
+        private final Spatial target;
+
+        public ArmatureDebuggerLink(Spatial target) {
+            this.target = target;
+        }
+
+        @Override
+        protected void controlUpdate(float tpf) {
+            spatial.setLocalTransform(target.getWorldTransform());
+        }
+
+        @Override
+        protected void controlRender(RenderManager rm, ViewPort vp) {
+        }
     }
 }

+ 136 - 50
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureDebugger.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,9 +29,11 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
+package com.jme3.scene.debug.custom;
 
 import com.jme3.anim.Armature;
 import com.jme3.anim.Joint;
+import com.jme3.anim.SkinningControl;
 import com.jme3.asset.AssetManager;
 import com.jme3.collision.Collidable;
 import com.jme3.collision.CollisionResults;
@@ -55,104 +55,176 @@ import java.util.List;
 public class ArmatureDebugger extends Node {
 
     /**
-     * The lines of the bones or the wires between their heads.
+     * The node responsible for rendering the bones/wires and their outlines.
      */
     private ArmatureNode armatureNode;
-
+    /**
+     * The {@link Armature} instance being debugged.
+     */
     private Armature armature;
-
+    /**
+     * A node containing all {@link Geometry} objects representing the joint points.
+     */
     private Node joints;
+    /**
+     * A node containing all {@link Geometry} objects representing the bone outlines.
+     */
     private Node outlines;
+    /**
+     * A node containing all {@link Geometry} objects representing the bone wires/lines.
+     */
     private Node wires;
     /**
      * The dotted lines between a bone's tail and the had of its children. Not
      * available if the length data was not provided.
      */
     private ArmatureInterJointsWire interJointWires;
-
+    /**
+     * Default constructor for `ArmatureDebugger`.
+     * Use {@link #ArmatureDebugger(String, Armature, List)} for a functional instance.
+     */
     public ArmatureDebugger() {
     }
 
     /**
-     * Creates a debugger with no length data. The wires will be a connection
-     * between the bones' heads only. The points will show the bones' heads only
-     * and no dotted line of inter bones connection will be visible.
+     * Convenience constructor that creates an {@code ArmatureDebugger} and immediately
+     * initializes its materials based on the provided {@code AssetManager}
+     * and {@code SkinningControl}.
+     *
+     * @param assetManager The {@link AssetManager} used to load textures and materials
+     *                     for the debug visualization.
+     * @param skControl    The {@link SkinningControl} from which to extract the
+     *                     {@link Armature} and its associated joints.
+     */
+    public ArmatureDebugger(AssetManager assetManager, SkinningControl skControl) {
+        this(null, skControl.getArmature(), skControl.getArmature().getJointList());
+        initialize(assetManager, null);
+    }
+
+    /**
+     * Creates an `ArmatureDebugger` instance without explicit bone length data.
+     * In this configuration, the visual representation will consist of wires
+     * connecting the bone heads, and points representing the bone heads.
+     * No dotted lines for inter-bone connections will be visible.
      *
-     * @param name     the name of the debugger's node
-     * @param armature the armature that will be shown
-     * @param deformingJoints a list of joints
+     * @param name            The name of this debugger's root node.
+     * @param armature        The {@link Armature} to be visualized.
+     * @param deformingJoints A {@link List} of {@link Joint} objects that are
+     *                        considered deforming joints.
      */
     public ArmatureDebugger(String name, Armature armature, List<Joint> deformingJoints) {
         super(name);
         this.armature = armature;
+        // Ensure the armature's world transforms are up-to-date before visualization.
         armature.update();
 
+        // Initialize the main container nodes for different visual elements.
         joints = new Node("joints");
         outlines = new Node("outlines");
         wires = new Node("bones");
         this.attachChild(joints);
         this.attachChild(outlines);
         this.attachChild(wires);
-        Node ndJoints = new Node("non deforming Joints");
-        Node ndOutlines = new Node("non deforming Joints outlines");
-        Node ndWires = new Node("non deforming Joints wires");
-        joints.attachChild(ndJoints);
-        outlines.attachChild(ndOutlines);
-        wires.attachChild(ndWires);
-        Node outlineDashed = new Node("Outlines Dashed");
-        Node wiresDashed = new Node("Wires Dashed");
-        wiresDashed.attachChild(new Node("dashed non defrom"));
-        outlineDashed.attachChild(new Node("dashed non defrom"));
+
+        // Create child nodes specifically for non-deforming joints' visualization
+        joints.attachChild(new Node("NonDeformingJoints"));
+        outlines.attachChild(new Node("NonDeformingOutlines"));
+        wires.attachChild(new Node("NonDeformingWires"));
+
+        Node outlineDashed = new Node("DashedOutlines");
+        outlineDashed.attachChild(new Node("DashedNonDeformingOutlines"));
         outlines.attachChild(outlineDashed);
+
+        Node wiresDashed = new Node("DashedWires");
+        wiresDashed.attachChild(new Node("DashedNonDeformingWires"));
         wires.attachChild(wiresDashed);
 
+        // Initialize the core ArmatureNode which handles the actual mesh generation.
         armatureNode = new ArmatureNode(armature, joints, wires, outlines, deformingJoints);
-
         this.attachChild(armatureNode);
 
+        // By default, non-deforming joints are hidden.
         displayNonDeformingJoint(false);
     }
 
+    /**
+     * Sets the visibility of non-deforming joints and their associated outlines and wires.
+     *
+     * @param display `true` to make non-deforming joints visible, `false` to hide them.
+     */
     public void displayNonDeformingJoint(boolean display) {
-        joints.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        outlines.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        wires.getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        ((Node) outlines.getChild(1)).getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
-        ((Node) wires.getChild(1)).getChild(0).setCullHint(display ? CullHint.Dynamic : CullHint.Always);
+        CullHint cullHint = display ? CullHint.Dynamic : CullHint.Always;
+
+        joints.getChild(0).setCullHint(cullHint);
+        outlines.getChild(0).setCullHint(cullHint);
+        wires.getChild(0).setCullHint(cullHint);
+
+        ((Node) outlines.getChild(1)).getChild(0).setCullHint(cullHint);
+        ((Node) wires.getChild(1)).getChild(0).setCullHint(cullHint);
     }
 
+    /**
+     * Initializes the materials and camera for the debugger's visual components.
+     * This method should be called after the `ArmatureDebugger` is added to a scene graph
+     * and an {@link AssetManager} and {@link Camera} are available.
+     *
+     * @param assetManager The {@link AssetManager} to load textures and materials.
+     * @param camera       The scene's primary {@link Camera}, used by the `ArmatureNode`
+     * for billboard rendering of joint points.
+     */
     public void initialize(AssetManager assetManager, Camera camera) {
 
         armatureNode.setCamera(camera);
 
-        Material matJoints = new Material(assetManager, "Common/MatDefs/Misc/Billboard.j3md");
-        Texture t = assetManager.loadTexture("Common/Textures/dot.png");
-        matJoints.setTexture("Texture", t);
-        matJoints.getAdditionalRenderState().setDepthTest(false);
-        matJoints.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        // Material for joint points (billboard dots).
+        Material matJoints = getJointMaterial(assetManager);
         joints.setQueueBucket(RenderQueue.Bucket.Translucent);
         joints.setMaterial(matJoints);
 
-        Material matWires = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
-        matWires.setBoolean("VertexColor", true);
-        matWires.getAdditionalRenderState().setLineWidth(1f);
+        // Material for bone wires/lines (unshaded, vertex colored).
+        Material matWires = getUnshadedMaterial(assetManager);
         wires.setMaterial(matWires);
 
-        Material matOutline = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
-        matOutline.setBoolean("VertexColor", true);
-        matOutline.getAdditionalRenderState().setLineWidth(1f);
+        // Material for dashed wires ("DashedLine.j3md" shader).
+        Material matWires2 = getDashedMaterial(assetManager);
+        wires.getChild(1).setMaterial(matWires2);
+
+        // Material for bone outlines (unshaded, vertex colored).
+        Material matOutline = getUnshadedMaterial(assetManager);
         outlines.setMaterial(matOutline);
 
-        Material matOutline2 = new Material(assetManager, "Common/MatDefs/Misc/DashedLine.j3md");
-        matOutline2.getAdditionalRenderState().setLineWidth(1);
+        // Material for dashed outlines ("DashedLine.j3md" shader).
+        Material matOutline2 = getDashedMaterial(assetManager);
         outlines.getChild(1).setMaterial(matOutline2);
+    }
 
-        Material matWires2 = new Material(assetManager, "Common/MatDefs/Misc/DashedLine.j3md");
-        matWires2.getAdditionalRenderState().setLineWidth(1);
-        wires.getChild(1).setMaterial(matWires2);
+    private Material getJointMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/Billboard.j3md");
+        Texture tex = asm.loadTexture("Common/Textures/dot.png");
+        mat.setTexture("Texture", tex);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
+        return mat;
+    }
+
+    private Material getUnshadedMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/Unshaded.j3md");
+        mat.setBoolean("VertexColor", true);
+        mat.getAdditionalRenderState().setDepthTest(false);
+        return mat;
+    }
 
+    private Material getDashedMaterial(AssetManager asm) {
+        Material mat = new Material(asm, "Common/MatDefs/Misc/DashedLine.j3md");
+        mat.getAdditionalRenderState().setDepthTest(false);
+        return mat;
     }
 
+    /**
+     * Returns the {@link Armature} instance associated with this debugger.
+     *
+     * @return The {@link Armature} being debugged.
+     */
     public Armature getArmature() {
         return armature;
     }
@@ -168,21 +240,35 @@ public class ArmatureDebugger extends Node {
         return armatureNode.collideWith(other, results);
     }
 
-    protected Joint select(Geometry g) {
-        return armatureNode.select(g);
+    /**
+     * Selects and returns the {@link Joint} associated with a given {@link Geometry}.
+     * This is an internal helper method, likely used for picking operations.
+     *
+     * @param geo The {@link Geometry} representing a part of a joint.
+     * @return The {@link Joint} corresponding to the geometry, or `null` if not found.
+     */
+    protected Joint select(Geometry geo) {
+        return armatureNode.select(geo);
     }
 
     /**
-     * @return the armature wires
+     * Returns the {@link ArmatureNode} which is responsible for generating and
+     * managing the visual mesh of the bones and wires.
+     *
+     * @return The {@link ArmatureNode} instance.
      */
     public ArmatureNode getBoneShapes() {
         return armatureNode;
     }
 
     /**
-     * @return the dotted line between bones (can be null)
+     * Returns the {@link ArmatureInterJointsWire} instance, which represents the
+     * dotted lines connecting a bone's tail to the head of its children.
+     * This will be `null` if the debugger was created without bone length data.
+     *
+     * @return The {@link ArmatureInterJointsWire} instance, or `null` if not present.
      */
     public ArmatureInterJointsWire getInterJointWires() {
         return interJointWires;
     }
-}
+}

+ 26 - 10
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureInterJointsWire.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,7 +29,7 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
-
+package com.jme3.scene.debug.custom;
 
 import com.jme3.math.Vector3f;
 import com.jme3.scene.Mesh;
@@ -46,20 +44,38 @@ import java.nio.FloatBuffer;
  * @author Marcin Roguski (Kaelthas)
  */
 public class ArmatureInterJointsWire extends Mesh {
-    private final Vector3f tmp = new Vector3f();
 
+    /**
+     * A temporary {@link Vector3f} used for calculations to avoid object allocation.
+     */
+    private final Vector3f tempVec = new Vector3f();
+
+    /**
+     * For serialization only. Do not use.
+     */
+    protected ArmatureInterJointsWire() {
+    }
 
+    /**
+     * Creates a new {@code ArmatureInterJointsWire} mesh.
+     * The mesh will be set up to draw lines from the {@code start} vector to each of the {@code ends} vectors.
+     *
+     * @param start The starting point of the lines (e.g., the bone tail's position). Not null.
+     * @param ends An array of ending points for the lines (e.g., the children's head positions). Not null.
+     */
     public ArmatureInterJointsWire(Vector3f start, Vector3f[] ends) {
         setMode(Mode.Lines);
         updateGeometry(start, ends);
     }
 
     /**
-     * For serialization only. Do not use.
+     * Updates the geometry of this mesh based on the provided start and end points.
+     * This method re-generates the position, texture coordinate, normal, and index buffers
+     * for the mesh.
+     *
+     * @param start The new starting point for the lines. Not null.
+     * @param ends An array of new ending points for the lines. Not null.
      */
-    protected ArmatureInterJointsWire() {
-    }
-
     protected void updateGeometry(Vector3f start, Vector3f[] ends) {
         float[] pos = new float[ends.length * 3 + 3];
         pos[0] = start.x;
@@ -78,7 +94,7 @@ public class ArmatureInterJointsWire extends Mesh {
         texCoord[0] = 0;
         texCoord[1] = 0;
         for (int i = 0; i < ends.length * 2; i++) {
-            texCoord[i + 2] = tmp.set(start).subtractLocal(ends[i / 2]).length();
+            texCoord[i + 2] = tempVec.set(start).subtractLocal(ends[i / 2]).length();
         }
         setBuffer(Type.TexCoord, 2, texCoord);
 

+ 215 - 57
jme3-core/src/main/java/com/jme3/scene/debug/custom/ArmatureNode.java

@@ -1,7 +1,5 @@
-package com.jme3.scene.debug.custom;
-
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -31,43 +29,73 @@ package com.jme3.scene.debug.custom;
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
+package com.jme3.scene.debug.custom;
 
 import com.jme3.anim.Armature;
 import com.jme3.anim.Joint;
-import com.jme3.collision.*;
-import com.jme3.math.*;
+import com.jme3.collision.Collidable;
+import com.jme3.collision.CollisionResult;
+import com.jme3.collision.CollisionResults;
+import com.jme3.math.ColorRGBA;
+import com.jme3.math.MathUtils;
+import com.jme3.math.Ray;
+import com.jme3.math.Vector2f;
+import com.jme3.math.Vector3f;
 import com.jme3.renderer.Camera;
 import com.jme3.renderer.queue.RenderQueue;
-import com.jme3.scene.*;
+import com.jme3.scene.Geometry;
+import com.jme3.scene.Mesh;
+import com.jme3.scene.Node;
+import com.jme3.scene.VertexBuffer;
 import com.jme3.scene.shape.Line;
 
 import java.nio.FloatBuffer;
-import java.util.*;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
 
 /**
- * The class that displays either wires between the bones' heads if no length
- * data is supplied and full bones' shapes otherwise.
+ * Renders an {@link Armature} for debugging purposes. It can display either
+ * wires connecting the heads of bones (if no length data is available) or
+ * full bone shapes (from head to tail) when length data is supplied.
  */
 public class ArmatureNode extends Node {
 
+    /**
+     * The size of the picking box in pixels for joint selection.
+     */
     public static final float PIXEL_BOX = 10f;
     /**
      * The armature to be displayed.
      */
     private final Armature armature;
     /**
-     * The map between the bone index and its length.
+     * Maps a {@link Joint} to its corresponding {@link Geometry} array.
+     * The array typically contains [jointGeometry, boneWireGeometry, boneOutlineGeometry].
      */
     private final Map<Joint, Geometry[]> jointToGeoms = new HashMap<>();
+    /**
+     * Maps a {@link Geometry} to its associated {@link Joint}. Used for picking.
+     */
     private final Map<Geometry, Joint> geomToJoint = new HashMap<>();
+    /**
+     * The currently selected joint.
+     */
     private Joint selectedJoint = null;
-    private final Vector3f tmp = new Vector3f();
-    private final Vector2f tmpv2 = new Vector2f();
+
+    // Temporary vectors for calculations to avoid repeated allocations
+    private final Vector3f tempVec3f = new Vector3f();
+    private final Vector2f tempVec2f = new Vector2f();
+
+    // Color constants for rendering
     private static final ColorRGBA selectedColor = ColorRGBA.Orange;
-    private static final ColorRGBA selectedColorJ = ColorRGBA.Yellow;
+    private static final ColorRGBA selectedColorJoint = ColorRGBA.Yellow;
     private static final ColorRGBA outlineColor = ColorRGBA.LightGray;
     private static final ColorRGBA baseColor = new ColorRGBA(0.05f, 0.05f, 0.05f, 1f);
 
+    /**
+     * The camera used for 2D picking calculations.
+     */
     private Camera camera;
 
 
@@ -88,27 +116,36 @@ public class ArmatureNode extends Node {
         setColor(origin, ColorRGBA.Green);
         attach(joints, true, origin);
 
+        // Recursively create geometries for all joints and bones in the armature
         for (Joint joint : armature.getRoots()) {
             createSkeletonGeoms(joint, joints, wires, outlines, deformingJoints);
         }
         this.updateModelBound();
-
     }
 
+    /**
+     * Recursively creates the geometries for a given joint and its children.
+     *
+     * @param joint           The current joint for which to create geometries.
+     * @param joints          The node for joint geometries.
+     * @param wires           The node for bone wire geometries.
+     * @param outlines        The node for bone outline geometries.
+     * @param deformingJoints A list of deforming joints.
+     */
     protected final void createSkeletonGeoms(Joint joint, Node joints, Node wires, Node outlines, List<Joint> deformingJoints) {
         Vector3f start = joint.getModelTransform().getTranslation().clone();
 
         Vector3f[] ends = null;
         if (!joint.getChildren().isEmpty()) {
             ends = new Vector3f[joint.getChildren().size()];
-        }
-
-        for (int i = 0; i < joint.getChildren().size(); i++) {
-            ends[i] = joint.getChildren().get(i).getModelTransform().getTranslation().clone();
+            for (int i = 0; i < ends.length; i++) {
+                ends[i] = joint.getChildren().get(i).getModelTransform().getTranslation().clone();
+            }
         }
 
         boolean deforms = deformingJoints.contains(joint);
 
+        // Create geometry for the joint head
         Geometry jGeom = new Geometry(joint.getName() + "Joint", new JointShape());
         jGeom.setLocalTranslation(start);
         attach(joints, deforms, jGeom);
@@ -134,8 +171,8 @@ public class ArmatureNode extends Node {
             setColor(bGeom, outlinesAttach == null ? outlineColor : baseColor);
             geomToJoint.put(bGeom, joint);
             bGeom.setUserData("start", getWorldTransform().transformVector(start, start));
-            for (int i = 0; i < ends.length; i++) {
-                getWorldTransform().transformVector(ends[i], ends[i]);
+            for (Vector3f end : ends) {
+                getWorldTransform().transformVector(end, end);
             }
             bGeom.setUserData("end", ends);
             bGeom.setQueueBucket(RenderQueue.Bucket.Transparent);
@@ -148,11 +185,17 @@ public class ArmatureNode extends Node {
         }
         jointToGeoms.put(joint, new Geometry[]{jGeom, bGeom, bGeomO});
 
+        // Recursively call for children
         for (Joint child : joint.getChildren()) {
             createSkeletonGeoms(child, joints, wires, outlines, deformingJoints);
         }
     }
 
+    /**
+     * Sets the camera to be used for 2D picking calculations.
+     *
+     * @param camera The camera to set.
+     */
     public void setCamera(Camera camera) {
         this.camera = camera;
     }
@@ -165,53 +208,83 @@ public class ArmatureNode extends Node {
         }
     }
 
-    protected Joint select(Geometry g) {
-        if (g == null) {
+    /**
+     * Selects a joint based on its associated geometry.
+     * If the selected geometry is already the current selection, no change occurs.
+     * Resets the selection if {@code geometry} is null.
+     *
+     * @param geo The geometry representing the joint or bone to select.
+     * @return The newly selected {@link Joint}, or null if no joint was selected or the selection was reset.
+     */
+    protected Joint select(Geometry geo) {
+        if (geo == null) {
             resetSelection();
             return null;
         }
-        Joint j = geomToJoint.get(g);
-        if (j != null) {
-            if (selectedJoint == j) {
+        Joint jointToSelect = geomToJoint.get(geo);
+        if (jointToSelect != null) {
+            if (selectedJoint == jointToSelect) {
                 return null;
             }
             resetSelection();
-            selectedJoint = j;
+            selectedJoint = jointToSelect;
             Geometry[] geomArray = jointToGeoms.get(selectedJoint);
-            setColor(geomArray[0], selectedColorJ);
+            // Color the joint head
+            setColor(geomArray[0], selectedColorJoint);
 
+            // Color the bone wire
             if (geomArray[1] != null) {
                 setColor(geomArray[1], selectedColor);
             }
 
+            // Restore outline color if present (as it's often the base color when bone is selected)
             if (geomArray[2] != null) {
                 setColor(geomArray[2], baseColor);
             }
-            return j;
+            return jointToSelect;
         }
         return null;
     }
 
+    /**
+     * Resets the color of the currently selected joint and bone geometries to their default colors
+     * and clears the {@code selectedJoint}.
+     */
     private void resetSelection() {
         if (selectedJoint == null) {
             return;
         }
         Geometry[] geoms = jointToGeoms.get(selectedJoint);
+        // Reset joint head color
         setColor(geoms[0], ColorRGBA.White);
+
+        // Reset bone wire color (depends on whether it has an outline)
         if (geoms[1] != null) {
             setColor(geoms[1], geoms[2] == null ? outlineColor : baseColor);
         }
+
+        // Reset bone outline color
         if (geoms[2] != null) {
             setColor(geoms[2], outlineColor);
         }
         selectedJoint = null;
     }
 
+    /**
+     * Returns the currently selected joint.
+     *
+     * @return The {@link Joint} that is currently selected, or null if no joint is selected.
+     */
     protected Joint getSelectedJoint() {
         return selectedJoint;
     }
 
-
+    /**
+     * Updates the geometries associated with a given joint and its children to reflect their
+     * current model transforms. This method is called recursively.
+     *
+     * @param joint The joint to update.
+     */
     protected final void updateSkeletonGeoms(Joint joint) {
         Geometry[] geoms = jointToGeoms.get(joint);
         if (geoms != null) {
@@ -232,70 +305,142 @@ public class ArmatureNode extends Node {
                         updateBoneMesh(bGeomO, start, ends);
                     }
                     bGeom.setUserData("start", getWorldTransform().transformVector(start, start));
-                    for (int i = 0; i < ends.length; i++) {
-                        getWorldTransform().transformVector(ends[i], ends[i]);
+                    for (Vector3f end : ends) {
+                        getWorldTransform().transformVector(end, end);
                     }
                     bGeom.setUserData("end", ends);
-
                 }
             }
         }
 
+        // Recursively update children
         for (Joint child : joint.getChildren()) {
             updateSkeletonGeoms(child);
         }
     }
 
+    /**
+     * Sets the color of the head geometry for a specific joint.
+     *
+     * @param joint The joint whose head color is to be set.
+     * @param color The new color for the joint head.
+     */
+    public void setHeadColor(Joint joint, ColorRGBA color) {
+        Geometry[] geomArray = jointToGeoms.get(joint);
+        setColor(geomArray[0], color);
+    }
+
+    /**
+     * Sets the color of all joint head geometries.
+     *
+     * @param color The new color for all joint heads.
+     */
+    public void setHeadColor(ColorRGBA color) {
+        for (Geometry[] geomArray : jointToGeoms.values()) {
+            setColor(geomArray[0], color);
+        }
+    }
+
+    /**
+     * Sets the color of all bone line geometries.
+     *
+     * @param color The new color for all bone lines.
+     */
+    public void setLineColor(ColorRGBA color) {
+        for (Geometry[] geomArray : jointToGeoms.values()) {
+            if (geomArray[1] != null) {
+                setColor(geomArray[1], color);
+            }
+        }
+    }
+
+    /**
+     * Performs a 2D pick operation to find joints or bones near the given cursor position.
+     * This method primarily checks for joint heads within a {@link #PIXEL_BOX} box
+     * around the cursor, and then checks for bone wires.
+     *
+     * @param cursor  The 2D screen coordinates of the pick ray origin.
+     * @param results The {@link CollisionResults} to store the pick results.
+     * @return The number of collisions found.
+     */
     public int pick(Vector2f cursor, CollisionResults results) {
+        if (camera == null) {
+            return 0;
+        }
 
-        for (Geometry g : geomToJoint.keySet()) {
-            if (g.getMesh() instanceof JointShape) {
-                camera.getScreenCoordinates(g.getWorldTranslation(), tmp);
-                if (cursor.x <= tmp.x + PIXEL_BOX && cursor.x >= tmp.x - PIXEL_BOX
-                        && cursor.y <= tmp.y + PIXEL_BOX && cursor.y >= tmp.y - PIXEL_BOX) {
+        int collisions = 0;
+        for (Geometry geo : geomToJoint.keySet()) {
+            if (geo.getMesh() instanceof JointShape) {
+                camera.getScreenCoordinates(geo.getWorldTranslation(), tempVec3f);
+                if (cursor.x <= tempVec3f.x + PIXEL_BOX && cursor.x >= tempVec3f.x - PIXEL_BOX
+                        && cursor.y <= tempVec3f.y + PIXEL_BOX && cursor.y >= tempVec3f.y - PIXEL_BOX) {
                     CollisionResult res = new CollisionResult();
-                    res.setGeometry(g);
+                    res.setGeometry(geo);
                     results.addCollision(res);
+                    collisions++;
                 }
             }
         }
-        return 0;
+        return collisions;
     }
 
+    /**
+     * Collides this {@code ArmatureNode} with a {@link Collidable} object, typically a {@link Ray}.
+     * It prioritizes 2D picking for joint heads and then performs a distance-based check for bone wires.
+     *
+     * @param other   The {@link Collidable} object to collide with.
+     * @param results The {@link CollisionResults} to store the collision information.
+     * @return The number of collisions found.
+     */
     @Override
     public int collideWith(Collidable other, CollisionResults results) {
-        if (!(other instanceof Ray)) {
+        if (!(other instanceof Ray) || camera == null) {
             return 0;
         }
 
-        // first try a 2D pick;
-        camera.getScreenCoordinates(((Ray)other).getOrigin(),tmp);
-        tmpv2.x = tmp.x;
-        tmpv2.y = tmp.y;
-        int nbHit = pick(tmpv2, results);
-        if (nbHit > 0) {
-            return nbHit;
+        // First, try a 2D pick for joint heads
+        camera.getScreenCoordinates(((Ray) other).getOrigin(), tempVec3f);
+        tempVec2f.x = tempVec3f.x;
+        tempVec2f.y = tempVec3f.y;
+        int hitCount = pick(tempVec2f, results);
+
+        // If 2D pick found hits, return them. Otherwise, proceed with bone wire collision.
+        if (hitCount > 0) {
+            return hitCount;
         }
 
+        // Check for bone wire collisions
         for (Geometry g : geomToJoint.keySet()) {
             if (g.getMesh() instanceof JointShape) {
+                // Skip joint heads, already handled by 2D pick
                 continue;
             }
+
             Vector3f start = g.getUserData("start");
             Vector3f[] ends = g.getUserData("end");
-            for (int i = 0; i < ends.length; i++) {
-                float len = MathUtils.raySegmentShortestDistance((Ray) other, start, ends[i], camera);
-                if (len > 0 && len < PIXEL_BOX) {
+
+            for (Vector3f end : ends) {
+                // Calculate the shortest distance from ray to bone segment
+                float dist = MathUtils.raySegmentShortestDistance((Ray) other, start, end, camera);
+                if (dist > 0 && dist < PIXEL_BOX) {
                     CollisionResult res = new CollisionResult();
                     res.setGeometry(g);
                     results.addCollision(res);
-                    nbHit++;
+                    hitCount++;
                 }
             }
         }
-        return nbHit;
+        return hitCount;
     }
 
+    /**
+     * Updates the mesh of a bone geometry (either {@link ArmatureInterJointsWire} or {@link Line})
+     * with new start and end points.
+     *
+     * @param geom  The bone geometry whose mesh needs updating.
+     * @param start The new starting point of the bone.
+     * @param ends  The new ending points of the bone (can be multiple for {@link ArmatureInterJointsWire}).
+     */
     private void updateBoneMesh(Geometry geom, Vector3f start, Vector3f[] ends) {
         if (geom.getMesh() instanceof ArmatureInterJointsWire) {
             ((ArmatureInterJointsWire) geom.getMesh()).updatePoints(start, ends);
@@ -305,18 +450,31 @@ public class ArmatureNode extends Node {
         geom.updateModelBound();
     }
 
-    private void setColor(Geometry g, ColorRGBA color) {
-        float[] colors = new float[g.getMesh().getVertexCount() * 4];
-        for (int i = 0; i < g.getMesh().getVertexCount() * 4; i += 4) {
+    /**
+     * Sets the color of a given geometry's vertex buffer.
+     * This method creates a new color buffer or updates an existing one with the specified color.
+     *
+     * @param geo   The geometry whose color is to be set.
+     * @param color The {@link ColorRGBA} to apply.
+     */
+    private void setColor(Geometry geo, ColorRGBA color) {
+        Mesh mesh = geo.getMesh();
+        int vertexCount = mesh.getVertexCount();
+
+        float[] colors = new float[vertexCount * 4];
+        for (int i = 0; i < colors.length; i += 4) {
             colors[i] = color.r;
             colors[i + 1] = color.g;
             colors[i + 2] = color.b;
             colors[i + 3] = color.a;
         }
-        VertexBuffer colorBuff = g.getMesh().getBuffer(VertexBuffer.Type.Color);
+
+        VertexBuffer colorBuff = geo.getMesh().getBuffer(VertexBuffer.Type.Color);
         if (colorBuff == null) {
-            g.getMesh().setBuffer(VertexBuffer.Type.Color, 4, colors);
+            // If no color buffer exists, create a new one
+            geo.getMesh().setBuffer(VertexBuffer.Type.Color, 4, colors);
         } else {
+            // If a color buffer exists, update its data
             FloatBuffer cBuff = (FloatBuffer) colorBuff.getData();
             cBuff.rewind();
             cBuff.put(colors);

+ 52 - 6
jme3-core/src/main/java/com/jme3/shader/VarType.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2024 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -45,6 +45,9 @@ import com.jme3.texture.TextureArray;
 import com.jme3.texture.TextureCubeMap;
 import com.jme3.texture.TextureImage;
 
+/**
+ * Enum representing various GLSL variable types and their corresponding Java types.
+ */
 public enum VarType {
     
     Float("float", float.class, Float.class),
@@ -59,7 +62,7 @@ public enum VarType {
     Vector4Array(true, false, "vec4", Vector4f[].class),
     
     Int("int", int.class, Integer.class),
-    Boolean("bool", Boolean.class, boolean.class),
+    Boolean("bool", boolean.class, Boolean.class),
 
     Matrix3(true, false, "mat3", Matrix3f.class),
     Matrix4(true, false, "mat4", Matrix4f.class),
@@ -83,8 +86,14 @@ public enum VarType {
     private boolean textureType = false;
     private boolean imageType = false;
     private final String glslType;
-    private Class<?>[] javaTypes;
+    private final Class<?>[] javaTypes;
 
+    /**
+     * Constructs a VarType with the specified GLSL type and corresponding Java types.
+     *
+     * @param glslType  the GLSL type name(s)
+     * @param javaTypes the Java classes mapped to this GLSL type
+     */
     VarType(String glslType, Class<?>... javaTypes) {
         this.glslType = glslType;
         if (javaTypes != null) {
@@ -94,6 +103,14 @@ public enum VarType {
         }
     }
 
+    /**
+     * Constructs a VarType with additional flags for multi-data and texture types.
+     *
+     * @param multiData   true if this type uses multiple data elements (e.g. arrays, matrices)
+     * @param textureType true if this type represents a texture sampler
+     * @param glslType    the GLSL type name(s)
+     * @param javaTypes   the Java classes mapped to this GLSL type
+     */
     VarType(boolean multiData, boolean textureType, String glslType, Class<?>... javaTypes) {
         this.usesMultiData = multiData;
         this.textureType = textureType;
@@ -104,7 +121,16 @@ public enum VarType {
             this.javaTypes = new Class<?>[0];
         }
     }
-    
+
+    /**
+     * Constructs a VarType with flags for multi-data, texture, and image types.
+     *
+     * @param multiData   true if this type uses multiple data elements
+     * @param textureType true if this type represents a texture sampler
+     * @param imageType   true if this type represents an image
+     * @param glslType    the GLSL type name(s)
+     * @param javaTypes   the Java classes mapped to this GLSL type
+     */
     VarType(boolean multiData, boolean textureType, boolean imageType, String glslType, Class<?>... javaTypes) {
         this(multiData, textureType, glslType, javaTypes);
         this.imageType = imageType;
@@ -127,25 +153,45 @@ public enum VarType {
 
     /**
      * Get the java types mapped to this VarType
-     * 
+     *
      * @return an array of classes mapped to this VarType
      */
     public Class<?>[] getJavaType() {
         return javaTypes;
     }
 
+    /**
+     * Returns whether this VarType represents a texture sampler type.
+     *
+     * @return true if this is a texture type, false otherwise
+     */
     public boolean isTextureType() {
         return textureType;
     }
-    
+
+    /**
+     * Returns whether this VarType represents an image type.
+     *
+     * @return true if this is an image type, false otherwise
+     */
     public boolean isImageType() {
         return imageType;
     }
 
+    /**
+     * Returns whether this VarType uses multiple data elements (e.g. arrays or matrices).
+     *
+     * @return true if this type uses multiple data elements, false otherwise
+     */
     public boolean usesMultiData() {
         return usesMultiData;
     }
 
+    /**
+     * Returns the GLSL type name(s) associated with this VarType.
+     *
+     * @return the GLSL type string (e.g. "float", "vec3", "sampler2D")
+     */
     public String getGlslType() {
         return glslType;
     }

+ 18 - 30
jme3-core/src/main/java/com/jme3/shadow/AbstractShadowFilter.java

@@ -32,10 +32,6 @@
 package com.jme3.shadow;
 
 import com.jme3.asset.AssetManager;
-import com.jme3.export.InputCapsule;
-import com.jme3.export.JmeExporter;
-import com.jme3.export.JmeImporter;
-import com.jme3.export.OutputCapsule;
 import com.jme3.material.Material;
 import com.jme3.material.RenderState;
 import com.jme3.math.Matrix4f;
@@ -45,38 +41,38 @@ import com.jme3.renderer.RenderManager;
 import com.jme3.renderer.ViewPort;
 import com.jme3.renderer.queue.RenderQueue;
 import com.jme3.texture.FrameBuffer;
+import com.jme3.util.TempVars;
 import com.jme3.util.clone.Cloner;
 import com.jme3.util.clone.JmeCloneable;
 
-import java.io.IOException;
-
 /**
  * Generic abstract filter that holds common implementations for the different
  * shadow filters
  *
  * @author Rémy Bouquet aka Nehon
  */
-public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> extends Filter implements Cloneable, JmeCloneable {
+public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> extends Filter implements JmeCloneable {
 
     protected T shadowRenderer;
     protected ViewPort viewPort;
 
+    private final Vector4f tempVec4 = new Vector4f();
+    private final Matrix4f tempMat4 = new Matrix4f();
+
     /**
      * For serialization only. Do not use.
      */
     protected AbstractShadowFilter() {
     }
-    
+
     /**
-     * Abstract class constructor
+     * Creates an AbstractShadowFilter. Subclasses invoke this constructor.
      *
-     * @param manager the application asset manager
-     * @param shadowMapSize the size of the rendered shadowmaps (512,1024,2048,
-     * etc...)
-     * @param shadowRenderer the shadowRenderer to use for this Filter
+     * @param assetManager The application's asset manager.
+     * @param shadowMapSize The size of the rendered shadow maps (e.g., 512, 1024, 2048).
+     * @param shadowRenderer The shadowRenderer to use for this Filter
      */
-    @SuppressWarnings("all")
-    protected AbstractShadowFilter(AssetManager manager, int shadowMapSize, T shadowRenderer) {
+    protected AbstractShadowFilter(AssetManager assetManager, int shadowMapSize, T shadowRenderer) {
         super("Post Shadow");
         this.shadowRenderer = shadowRenderer;
         // this is legacy setting for shadows with backface shadows
@@ -93,18 +89,21 @@ public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> ext
         return true;
     }
 
+    /**
+     * @deprecated Use {@link #getMaterial()} instead.
+     * @return The Material used by this filter.
+     */
+    @Deprecated
     public Material getShadowMaterial() {       
         return material;
     }
 
-    Vector4f tmpv = new Vector4f();
-
     @Override
     protected void preFrame(float tpf) {
         shadowRenderer.preFrame(tpf);
-        material.setMatrix4("ViewProjectionMatrixInverse", viewPort.getCamera().getViewProjectionMatrix().invert());
         Matrix4f m = viewPort.getCamera().getViewProjectionMatrix();
-        material.setVector4("ViewProjectionMatrixRow2", tmpv.set(m.m20, m.m21, m.m22, m.m23));
+        material.setMatrix4("ViewProjectionMatrixInverse", tempMat4.set(m).invertLocal());
+        material.setVector4("ViewProjectionMatrixRow2", tempVec4.set(m.m20, m.m21, m.m22, m.m23));
     }
 
     @Override
@@ -337,15 +336,4 @@ public abstract class AbstractShadowFilter<T extends AbstractShadowRenderer> ext
         shadowRenderer.setPostShadowMaterial(material);
     }
 
-    @Override
-    public void write(JmeExporter ex) throws IOException {
-        super.write(ex);
-        OutputCapsule oc = ex.getCapsule(this);
-    }
-
-    @Override
-    public void read(JmeImporter im) throws IOException {
-        super.read(im);
-        InputCapsule ic = im.getCapsule(this);
-    }
 }

+ 144 - 39
jme3-core/src/main/java/com/jme3/system/AppSettings.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2022 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -39,6 +39,8 @@ import java.io.OutputStream;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Properties;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import java.util.prefs.BackingStoreException;
 import java.util.prefs.Preferences;
 
@@ -58,6 +60,8 @@ public final class AppSettings extends HashMap<String, Object> {
 
     private static final long serialVersionUID = 1L;
 
+    private static final Logger logger = Logger.getLogger(AppSettings.class.getName());
+
     private static final AppSettings defaults = new AppSettings(false);
 
     /**
@@ -295,6 +299,7 @@ public final class AppSettings extends HashMap<String, Object> {
         defaults.put("UseRetinaFrameBuffer", false);
         defaults.put("WindowYPosition", 0);
         defaults.put("WindowXPosition", 0);
+        defaults.put("X11PlatformPreferred", false);
         //  defaults.put("Icons", null);
     }
 
@@ -507,12 +512,25 @@ public final class AppSettings extends HashMap<String, Object> {
      * @return the corresponding value, or 0 if not set
      */
     public int getInteger(String key) {
-        Integer i = (Integer) get(key);
-        if (i == null) {
-            return 0;
-        }
+        return getInteger(key, 0);
+    }
 
-        return i.intValue();
+    /**
+     * Get an integer from the settings.
+     * <p>
+     * If the key is not set, or the stored value is not an Integer, then the
+     * provided default value is returned.
+     *
+     * @param key the key of an integer setting
+     * @param defaultValue the value to return if the key is not found or the
+     * value is not an integer
+     */
+    public int getInteger(String key, int defaultValue) {
+        Object val = get(key);
+        if (val == null) {
+            return defaultValue;
+        }
+        return (Integer) val;
     }
 
     /**
@@ -524,12 +542,25 @@ public final class AppSettings extends HashMap<String, Object> {
      * @return the corresponding value, or false if not set
      */
     public boolean getBoolean(String key) {
-        Boolean b = (Boolean) get(key);
-        if (b == null) {
-            return false;
-        }
+        return getBoolean(key, false);
+    }
 
-        return b.booleanValue();
+    /**
+     * Get a boolean from the settings.
+     * <p>
+     * If the key is not set, or the stored value is not a Boolean, then the
+     * provided default value is returned.
+     *
+     * @param key the key of a boolean setting
+     * @param defaultValue the value to return if the key is not found or the
+     * value is not a boolean
+     */
+    public boolean getBoolean(String key, boolean defaultValue) {
+        Object val = get(key);
+        if (val == null) {
+            return defaultValue;
+        }
+        return (Boolean) val;
     }
 
     /**
@@ -541,12 +572,25 @@ public final class AppSettings extends HashMap<String, Object> {
      * @return the corresponding value, or null if not set
      */
     public String getString(String key) {
-        String s = (String) get(key);
-        if (s == null) {
-            return null;
-        }
+        return getString(key, null);
+    }
 
-        return s;
+    /**
+     * Get a string from the settings.
+     * <p>
+     * If the key is not set, or the stored value is not a String, then the
+     * provided default value is returned.
+     *
+     * @param key the key of a string setting
+     * @param defaultValue the value to return if the key is not found or the
+     * value is not a string
+     */
+    public String getString(String key, String defaultValue) {
+        Object val = get(key);
+        if (val == null) {
+            return defaultValue;
+        }
+        return (String) val;
     }
 
     /**
@@ -558,12 +602,25 @@ public final class AppSettings extends HashMap<String, Object> {
      * @return the corresponding value, or 0 if not set
      */
     public float getFloat(String key) {
-        Float f = (Float) get(key);
-        if (f == null) {
-            return 0f;
-        }
+        return getFloat(key, 0f);
+    }
 
-        return f.floatValue();
+    /**
+     * Get a float from the settings.
+     * <p>
+     * If the key is not set, or the stored value is not a Float, then the
+     * provided default value is returned.
+     *
+     * @param key the key of a float setting
+     * @param defaultValue the value to return if the key is not found or the
+     * value is not a float
+     */
+    public float getFloat(String key, float defaultValue) {
+        Object val = get(key);
+        if (val == null) {
+            return defaultValue;
+        }
+        return (Float) val;
     }
 
     /**
@@ -573,7 +630,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * @param value the desired integer value
      */
     public void putInteger(String key, int value) {
-        put(key, Integer.valueOf(value));
+        put(key, value);
     }
 
     /**
@@ -583,7 +640,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * @param value the desired boolean value
      */
     public void putBoolean(String key, boolean value) {
-        put(key, Boolean.valueOf(value));
+        put(key, value);
     }
 
     /**
@@ -603,7 +660,7 @@ public final class AppSettings extends HashMap<String, Object> {
      * @param value the desired float value
      */
     public void putFloat(String key, float value) {
-        put(key, Float.valueOf(value));
+        put(key, value);
     }
 
     /**
@@ -698,9 +755,9 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Set the graphics renderer to use, one of:<br>
      * <ul>
-     * <li>AppSettings.LWJGL_OPENGL1 - Force OpenGL1.1 compatability</li>
-     * <li>AppSettings.LWJGL_OPENGL2 - Force OpenGL2 compatability</li>
-     * <li>AppSettings.LWJGL_OPENGL3 - Force OpenGL3.3 compatability</li>
+     * <li>AppSettings.LWJGL_OPENGL1 - Force OpenGL1.1 compatibility</li>
+     * <li>AppSettings.LWJGL_OPENGL2 - Force OpenGL2 compatibility</li>
+     * <li>AppSettings.LWJGL_OPENGL3 - Force OpenGL3.3 compatibility</li>
      * <li>AppSettings.LWJGL_OPENGL_ANY - Choose an appropriate
      * OpenGL version based on system capabilities</li>
      * <li>AppSettings.JOGL_OPENGL_BACKWARD_COMPATIBLE</li>
@@ -739,7 +796,7 @@ public final class AppSettings extends HashMap<String, Object> {
     }
 
     /**
-     * @param value the width for the default framebuffer.
+     * @param value the width for the default frame buffer.
      * (Default: 640)
      */
     public void setWidth(int value) {
@@ -747,7 +804,7 @@ public final class AppSettings extends HashMap<String, Object> {
     }
 
     /**
-     * @param value the height for the default framebuffer.
+     * @param value the height for the default frame buffer.
      * (Default: 480)
      */
     public void setHeight(int value) {
@@ -755,7 +812,7 @@ public final class AppSettings extends HashMap<String, Object> {
     }
 
     /**
-     * Set the resolution for the default framebuffer
+     * Set the resolution for the default frame buffer
      * Use {@link #setWindowSize(int, int)} instead, for HiDPI display support.
      * @param width The width
      * @param height The height
@@ -769,8 +826,8 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Set the size of the window
      *
-     * @param width The width in pixels (default = width of the default framebuffer)
-     * @param height The height in pixels (default = height of the default framebuffer)
+     * @param width The width in pixels (default = width of the default frame buffer)
+     * @param height The height in pixels (default = height of the default frame buffer)
      */
     public void setWindowSize(int width, int height) {
         putInteger("WindowWidth", width);
@@ -960,7 +1017,7 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Enable or disable gamma correction. If enabled, the main framebuffer will
      * be configured for sRGB colors, and sRGB images will be linearized.
-     *
+     * <p>
      * Gamma correction requires a GPU that supports GL_ARB_framebuffer_sRGB;
      * otherwise this setting will be ignored.
      *
@@ -971,7 +1028,7 @@ public final class AppSettings extends HashMap<String, Object> {
     }
 
     /**
-     * Get the framerate.
+     * Get the frame rate.
      *
      * @return the maximum rate (in frames per second), or -1 for unlimited
      * @see #setFrameRate(int)
@@ -1004,7 +1061,7 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Get the width
      *
-     * @return the width of the default framebuffer (in pixels)
+     * @return the width of the default frame buffer (in pixels)
      * @see #setWidth(int)
      */
     public int getWidth() {
@@ -1014,7 +1071,7 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Get the height
      *
-     * @return the height of the default framebuffer (in pixels)
+     * @return the height of the default frame buffer (in pixels)
      * @see #setHeight(int)
      */
     public int getHeight() {
@@ -1215,7 +1272,7 @@ public final class AppSettings extends HashMap<String, Object> {
 
     /**
      * Allows the display window to be resized by dragging its edges.
-     *
+     * <p>
      * Only supported for {@link JmeContext.Type#Display} contexts which
      * are in windowed mode, ignored for other types.
      * The default value is <code>false</code>.
@@ -1240,7 +1297,7 @@ public final class AppSettings extends HashMap<String, Object> {
 
     /**
      * When enabled the display context will swap buffers every frame.
-     *
+     * <p>
      * This may need to be disabled when integrating with an external
      * library that handles buffer swapping on its own, e.g. Oculus Rift.
      * When disabled, the engine will process window messages
@@ -1282,7 +1339,7 @@ public final class AppSettings extends HashMap<String, Object> {
     /**
      * Sets a custom platform chooser. This chooser specifies which platform and
      * which devices are used for the OpenCL context.
-     *
+     * <p>
      * Default: an implementation defined one.
      *
      * @param chooser the class of the chooser, must have a default constructor
@@ -1507,4 +1564,52 @@ public final class AppSettings extends HashMap<String, Object> {
     public void setDisplay(int mon) {
         putInteger("Display", mon);
     }
+
+    /**
+     * Prints all key-value pairs stored under a given preferences key
+     * in the Java Preferences API to standard output.
+     *
+     * @param preferencesKey The preferences key (node path) to inspect.
+     * @throws BackingStoreException If an exception occurs while accessing the preferences.
+     */
+    public static void printPreferences(String preferencesKey) throws BackingStoreException {
+        Preferences prefs = Preferences.userRoot().node(preferencesKey);
+        String[] keys = prefs.keys();
+
+        if (keys == null || keys.length == 0) {
+            logger.log(Level.WARNING, "No Preferences found under key: {0}", preferencesKey);
+        } else {
+            StringBuilder sb = new StringBuilder();
+            sb.append("Preferences for key: ").append(preferencesKey);
+            for (String key : keys) {
+                // Retrieve the value as a String (default fallback for Preferences API)
+                String value = prefs.get(key, "[Value Not Found]");
+                sb.append("\n * ").append(key).append(" = ").append(value);
+            }
+            logger.log(Level.INFO, sb.toString());
+        }
+    }
+    /**
+     * Sets the preferred native platform for creating the GL context on Linux distributions.
+     * <p>
+     * This setting is relevant for Linux distributions or derivatives that utilize a Wayland session alongside an X11 via the XWayland bridge.
+     * Enabling this option allows the use of GLX for window positioning and/or icon configuration.
+     *
+     * @param preferred true to prefer GLX (native X11) for the GL context, false to prefer EGL (native Wayland).
+     */
+    public void setX11PlatformPreferred(boolean preferred) {
+        putBoolean("X11PlatformPreferred", preferred);
+    }
+    
+    /**
+     * Determines which native platform is preferred for GL context creation on Linux distributions.
+     * <p>
+     * This setting is only valid on Linux distributions or derivatives that support Wayland,
+     * and it indicates whether GLX (native X11) or EGL (native Wayland) is enabled for the GL context.
+     *
+     * @return true if GLX is preferred, otherwise false if EGL is preferred (native Wayland).
+     */
+    public boolean isX11PlatformPreferred() {
+        return getBoolean("X11PlatformPreferred");
+    }
 }

+ 38 - 0
jme3-core/src/main/resources/Common/MatDefs/Misc/Dashed.j3md

@@ -0,0 +1,38 @@
+MaterialDef Dashed {
+    MaterialParameters {
+        Float DashSize
+        Vector4 Color
+    }
+    Technique {
+        WorldParameters {
+            WorldViewProjectionMatrix
+        }
+        VertexShaderNodes {
+            ShaderNode CommonVert {
+                Definition : CommonVert : Common/MatDefs/ShaderNodes/Common/CommonVert.j3sn
+                InputMappings {
+                    worldViewProjectionMatrix = WorldParam.WorldViewProjectionMatrix
+                    modelPosition = Global.position.xyz
+                    texCoord1 = Attr.inTexCoord
+                    vertColor = Attr.inColor
+                }
+                OutputMappings {
+                    Global.position = projPosition
+                }
+            }
+        }
+        FragmentShaderNodes {
+            ShaderNode Dashed {
+                Definition : Dashed : Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn
+                InputMappings {
+                    texCoord = CommonVert.texCoord1
+                    inColor = MatParam.Color
+                    size = MatParam.DashSize
+                }
+                OutputMappings {
+                    Global.color = outColor
+                }
+            }
+        }
+    }
+}

+ 23 - 0
jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern.j3sn

@@ -0,0 +1,23 @@
+ShaderNodeDefinitions{ 
+    ShaderNodeDefinition Dashed {      
+        Type: Fragment
+
+        Shader GLSL100: Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag
+        
+        Documentation{
+            Renders dashed lines            
+            @input vec2 texCoord The texture coordinates
+            @input float size the size of the dashes
+            @input vec4 inColor the color of the fragment so far
+            @outColor vec4 color the output color
+        }
+        Input {
+            vec2 texCoord
+            vec4 inColor
+            float size
+        }
+        Output {
+            vec4 outColor
+        }
+    }
+}

+ 9 - 0
jme3-core/src/main/resources/Common/MatDefs/ShaderNodes/Common/DashedPattern100.frag

@@ -0,0 +1,9 @@
+void main(){
+    //@input vec2 texCoord The texture coordinates
+    //@input float size the size of the dashes
+    //@output vec4 color the output color
+
+    //insert glsl code here
+    outColor = inColor;
+    outColor.a = step(1.0 - size, texCoord.x);
+}

BIN
jme3-core/src/main/resources/Common/Textures/lightbulb32.png


+ 153 - 72
jme3-core/src/plugins/java/com/jme3/audio/plugins/WAVLoader.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2021 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -47,39 +47,62 @@ import java.nio.ByteBuffer;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
+/**
+ * An {@code AssetLoader} for loading WAV audio files.
+ * This loader supports PCM (Pulse Code Modulation) WAV files,
+ * both as in-memory {@link AudioBuffer}s and streaming {@link AudioStream}s.
+ * It handles 8-bit and 16-bit audio formats.
+ *
+ * <p>The WAV file format consists of chunks. This loader specifically parses
+ * the 'RIFF', 'WAVE', 'fmt ', and 'data' chunks.
+ */
 public class WAVLoader implements AssetLoader {
 
     private static final Logger logger = Logger.getLogger(WAVLoader.class.getName());
 
-    // all these are in big endian
+    // RIFF chunk identifiers (Big-Endian representation of ASCII characters)
     private static final int i_RIFF = 0x46464952;
     private static final int i_WAVE = 0x45564157;
     private static final int i_fmt  = 0x20746D66;
     private static final int i_data = 0x61746164;
 
-    private boolean readStream = false;
-
-    private AudioBuffer audioBuffer;
-    private AudioStream audioStream;
-    private AudioData audioData;
+    /**
+     * The number of bytes per second for the audio data, calculated from the WAV header.
+     * Used to determine the duration of the audio.
+     */
     private int bytesPerSec;
+    /**
+     * The duration of the audio in seconds.
+     */
     private float duration;
-
+    /**
+     * The input stream for reading the WAV file data.
+     */
     private ResettableInputStream in;
-    private int inOffset = 0;
-    
+
+    /**
+     * A custom {@link InputStream} extension that handles little-endian byte
+     * reading and provides seek capabilities for streaming audio by reopening
+     * and skipping the input stream.
+     */
     private static class ResettableInputStream extends LittleEndien implements SeekableStream {
         
-        final private AssetInfo info;
+        private final AssetInfo info;
         private int resetOffset = 0;
         
         public ResettableInputStream(AssetInfo info, InputStream in) {
             super(in);
             this.info = info;
         }
-        
-        public void setResetOffset(int resetOffset) {
-            this.resetOffset = resetOffset;
+
+        /**
+         * Sets the offset from the beginning of the file to reset the stream to.
+         * This is typically the start of the audio data chunk.
+         *
+         * @param offset The byte offset to reset to.
+         */
+        public void setResetOffset(int offset) {
+            this.resetOffset = offset;
         }
 
         @Override
@@ -95,122 +118,170 @@ public class WAVLoader implements AssetLoader {
                 // Resource could have gotten lost, etc.
                 try {
                     newStream.close();
-                } catch (IOException ex2) {
+                } catch (IOException ignored) {
                 }
                 throw new RuntimeException(ex);
             }
         }
     }
 
-    private void readFormatChunk(int size) throws IOException{
+    /**
+     * Reads and parses the 'fmt ' (format) chunk of the WAV file.
+     * This chunk contains information about the audio format such as
+     * compression, channels, sample rate, bits per sample, etc.
+     *
+     * @param chunkSize The size of the 'fmt ' chunk in bytes.
+     * @param audioData The {@link AudioData} object to set the format information on.
+     * @throws IOException if the file is not a supported PCM WAV, or if format
+     * parameters are invalid.
+     */
+    private void readFormatChunk(int chunkSize, AudioData audioData) throws IOException {
         // if other compressions are supported, size doesn't have to be 16
 //        if (size != 16)
 //            logger.warning("Expected size of format chunk to be 16");
 
         int compression = in.readShort();
-        if (compression != 1){
+        if (compression != 1) { // 1 = PCM (Pulse Code Modulation)
             throw new IOException("WAV Loader only supports PCM wave files");
         }
 
-        int channels = in.readShort();
+        int numChannels = in.readShort();
         int sampleRate = in.readInt();
+        bytesPerSec = in.readInt(); // Average bytes per second
 
-        bytesPerSec = in.readInt(); // used to calculate duration
-
-        int bytesPerSample = in.readShort();
+        int bytesPerSample = in.readShort(); // Bytes per sample block (channels * bytesPerSample)
         int bitsPerSample = in.readShort();
 
-        int expectedBytesPerSec = (bitsPerSample * channels * sampleRate) / 8;
-        if (expectedBytesPerSec != bytesPerSec){
+        int expectedBytesPerSec = (bitsPerSample * numChannels * sampleRate) / 8;
+        if (expectedBytesPerSec != bytesPerSec) {
             logger.log(Level.WARNING, "Expected {0} bytes per second, got {1}",
                     new Object[]{expectedBytesPerSec, bytesPerSec});
         }
-        
+
         if (bitsPerSample != 8 && bitsPerSample != 16)
             throw new IOException("Only 8 and 16 bits per sample are supported!");
 
-        if ( (bitsPerSample / 8) * channels != bytesPerSample)
+        if ((bitsPerSample / 8) * numChannels != bytesPerSample)
             throw new IOException("Invalid bytes per sample value");
 
         if (bytesPerSample * sampleRate != bytesPerSec)
             throw new IOException("Invalid bytes per second value");
 
-        audioData.setupFormat(channels, bitsPerSample, sampleRate);
+        audioData.setupFormat(numChannels, bitsPerSample, sampleRate);
 
-        int remaining = size - 16;
-        if (remaining > 0){
-            in.skipBytes(remaining);
+        // Skip any extra parameters in the format chunk (e.g., for non-PCM formats)
+        int remainingChunkBytes = chunkSize - 16;
+        if (remainingChunkBytes > 0) {
+            in.skipBytes(remainingChunkBytes);
         }
     }
 
-    private void readDataChunkForBuffer(int len) throws IOException {
-        ByteBuffer data = BufferUtils.createByteBuffer(len);
-        byte[] buf = new byte[512];
+    /**
+     * Reads the 'data' chunk for an {@link AudioBuffer}. This involves loading
+     * the entire audio data into a {@link ByteBuffer} in memory.
+     *
+     * @param dataChunkSize The size of the 'data' chunk in bytes.
+     * @param audioBuffer   The {@link AudioBuffer} to update with the loaded data.
+     * @throws IOException if an error occurs while reading the data.
+     */
+    private void readDataChunkForBuffer(int dataChunkSize, AudioBuffer audioBuffer) throws IOException {
+        ByteBuffer data = BufferUtils.createByteBuffer(dataChunkSize);
+        byte[] buf = new byte[1024]; // Use a larger buffer for efficiency
         int read = 0;
-        while ( (read = in.read(buf)) > 0){
-            data.put(buf, 0, Math.min(read, data.remaining()) );
+        while ((read = in.read(buf)) > 0) {
+            data.put(buf, 0, Math.min(read, data.remaining()));
         }
         data.flip();
         audioBuffer.updateData(data);
         in.close();
     }
 
-    private void readDataChunkForStream(int offset, int len) throws IOException {
-        in.setResetOffset(offset);
+    /**
+     * Configures the {@link AudioStream} to stream data from the 'data' chunk.
+     * This involves setting the reset offset for seeking and passing the
+     * input stream and duration to the {@link AudioStream}.
+     *
+     * @param dataChunkOffset The byte offset from the start of the file where the 'data' chunk begins.
+     * @param dataChunkSize   The size of the 'data' chunk in bytes.
+     * @param audioStream     The {@link AudioStream} to configure.
+     */
+    private void readDataChunkForStream(int dataChunkOffset, int dataChunkSize, AudioStream audioStream) {
+        in.setResetOffset(dataChunkOffset);
         audioStream.updateData(in, duration);
     }
 
-    private AudioData load(AssetInfo info, InputStream inputStream, boolean stream) throws IOException{
+    /**
+     * Main loading logic for WAV files. This method parses the RIFF, WAVE, fmt,
+     * and data chunks to extract audio information and data.
+     *
+     * @param info        The {@link AssetInfo} for the WAV file.
+     * @param inputStream The initial {@link InputStream} opened for the asset.
+     * @param stream      A boolean indicating whether the audio should be loaded
+     *                    as a stream (true) or an in-memory buffer (false).
+     * @return The loaded {@link AudioData} (either {@link AudioBuffer} or {@link AudioStream}).
+     * @throws IOException if the file is not a valid WAV, or if any I/O error occurs.
+     */
+    private AudioData load(AssetInfo info, InputStream inputStream, boolean stream) throws IOException {
         this.in = new ResettableInputStream(info, inputStream);
-        inOffset = 0;
-        
-        int sig = in.readInt();
-        if (sig != i_RIFF)
+        int inOffset = 0;
+
+        // Read RIFF chunk
+        int riffId = in.readInt();
+        if (riffId != i_RIFF) {
             throw new IOException("File is not a WAVE file");
-        
-        // skip size
+        }
+
+        // Skip RIFF chunk size
         in.readInt();
-        if (in.readInt() != i_WAVE)
+
+        int waveId = in.readInt();
+        if (waveId != i_WAVE)
             throw new IOException("WAVE File does not contain audio");
 
-        inOffset += 4 * 3;
-        
-        readStream = stream;
-        if (readStream){
+        inOffset += 4 * 3; // RIFF_ID + ChunkSize + WAVE_ID
+
+        AudioData audioData;
+        AudioBuffer audioBuffer = null;
+        AudioStream audioStream = null;
+
+        if (stream) {
             audioStream = new AudioStream();
             audioData = audioStream;
-        }else{
+        } else {
             audioBuffer = new AudioBuffer();
             audioData = audioBuffer;
         }
 
         while (true) {
-            int type = in.readInt();
-            int len = in.readInt();
-            
-            inOffset += 4 * 2;
+            int chunkType = in.readInt();
+            int chunkSize = in.readInt();
+
+            inOffset += 4 * 2; // ChunkType + ChunkSize
 
-            switch (type) {
+            switch (chunkType) {
                 case i_fmt:
-                    readFormatChunk(len);
-                    inOffset += len;
+                    readFormatChunk(chunkSize, audioData);
+                    inOffset += chunkSize;
                     break;
                 case i_data:
                     // Compute duration based on data chunk size
-                    duration = len / bytesPerSec;
+                    duration = (float) (chunkSize / bytesPerSec);
 
-                    if (readStream) {
-                        readDataChunkForStream(inOffset, len);
+                    if (stream) {
+                        readDataChunkForStream(inOffset, chunkSize, audioStream);
                     } else {
-                        readDataChunkForBuffer(len);
+                        readDataChunkForBuffer(chunkSize, audioBuffer);
                     }
                     return audioData;
                 default:
-                    int skipped = in.skipBytes(len);
-                    if (skipped <= 0) {
+                    // Skip unknown chunks
+                    int skippedBytes = in.skipBytes(chunkSize);
+                    if (skippedBytes <= 0) {
+                        logger.log(Level.WARNING, "Reached end of stream prematurely while skipping unknown chunk of size {0}. Asset: {1}",
+                                new Object[]{chunkSize, info.getKey().getName()});
                         return null;
                     }
-                    inOffset += skipped;
+                    inOffset += skippedBytes;
                     break;
             }
         }
@@ -218,18 +289,28 @@ public class WAVLoader implements AssetLoader {
     
     @Override
     public Object load(AssetInfo info) throws IOException {
-        AudioData data;
-        InputStream inputStream = null;
+        InputStream is = null;
         try {
-            inputStream = info.openStream();
-            data = load(info, inputStream, ((AudioKey)info.getKey()).isStream());
-            if (data instanceof AudioStream){
-                inputStream = null;
+            is = info.openStream();
+            boolean streamAudio = ((AudioKey) info.getKey()).isStream();
+            AudioData loadedData = load(info, is, streamAudio);
+
+            // If it's an AudioStream, the internal inputStream is managed by the stream itself
+            // and should not be closed here.
+            if (loadedData instanceof AudioStream) {
+                // Prevent closing in finally block
+                is = null;
             }
-            return data;
+            return loadedData;
         } finally {
-            if (inputStream != null){
-                inputStream.close();
+            // Nullify/reset instance variables to ensure the loader instance is clean
+            // for the next load operation.
+            in = null;
+            bytesPerSec = 0;
+            duration = 0.0f;
+
+            if (is != null) {
+                is.close();
             }
         }
     }

+ 7 - 9
jme3-core/src/plugins/java/com/jme3/material/plugins/ShaderNodeDefinitionLoader.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2018 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -38,13 +38,14 @@ import com.jme3.asset.AssetLoader;
 import com.jme3.asset.ShaderNodeDefinitionKey;
 import com.jme3.util.blockparser.BlockLanguageParser;
 import com.jme3.util.blockparser.Statement;
+
 import java.io.IOException;
 import java.io.InputStream;
 import java.util.List;
 
 /**
  * ShaderNodeDefinition file loader (.j3sn)
- *
+ * <p>
  * a j3sn file is a block style file like j3md or j3m. It must contain one
  * ShaderNodeDefinition{} block that contains several ShaderNodeDefinition{}
  * blocks
@@ -53,16 +54,14 @@ import java.util.List;
  */
 public class ShaderNodeDefinitionLoader implements AssetLoader {
 
-    private ShaderNodeLoaderDelegate loaderDelegate;
-
     @Override
     public Object load(AssetInfo assetInfo) throws IOException {
-        AssetKey k = assetInfo.getKey();
-        if (!(k instanceof ShaderNodeDefinitionKey)) {
+        AssetKey<?> assetKey = assetInfo.getKey();
+        if (!(assetKey instanceof ShaderNodeDefinitionKey)) {
             throw new IOException("ShaderNodeDefinition file must be loaded via ShaderNodeDefinitionKey");
         }
-        ShaderNodeDefinitionKey key = (ShaderNodeDefinitionKey) k;
-        loaderDelegate = new ShaderNodeLoaderDelegate();
+        ShaderNodeDefinitionKey key = (ShaderNodeDefinitionKey) assetKey;
+        ShaderNodeLoaderDelegate loaderDelegate = new ShaderNodeLoaderDelegate();
 
         InputStream in = assetInfo.openStream();
         List<Statement> roots = BlockLanguageParser.parse(in);
@@ -80,6 +79,5 @@ public class ShaderNodeDefinitionLoader implements AssetLoader {
         }
 
         return loaderDelegate.readNodesDefinitions(roots.get(0).getContents(), key);
-
     }
 }

+ 79 - 23
jme3-examples/src/main/java/jme3test/export/TestOgreConvert.java

@@ -1,5 +1,5 @@
 /*
- * Copyright (c) 2009-2012 jMonkeyEngine
+ * Copyright (c) 2009-2025 jMonkeyEngine
  * All rights reserved.
  *
  * Redistribution and use in source and binary forms, with or without
@@ -29,53 +29,109 @@
  * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
-
 package jme3test.export;
 
 import com.jme3.anim.AnimComposer;
+import com.jme3.anim.SkinningControl;
 import com.jme3.app.SimpleApplication;
 import com.jme3.export.binary.BinaryExporter;
-import com.jme3.export.binary.BinaryImporter;
+import com.jme3.font.BitmapText;
+import com.jme3.light.AmbientLight;
 import com.jme3.light.DirectionalLight;
+import com.jme3.material.MatParamOverride;
 import com.jme3.math.ColorRGBA;
 import com.jme3.math.Vector3f;
-import com.jme3.scene.Node;
 import com.jme3.scene.Spatial;
 
-import java.io.*;
-
+/**
+ * This class is a jMonkeyEngine 3 (jME3) test application designed to verify
+ * the import, export, and runtime behavior of 3D models, particularly those
+ * in or compatible with the Ogre3D format (.mesh.xml).
+ * It loads an Ogre model, saves and reloads it using jME3's binary exporter,
+ * plays an animation, and displays debugging information about its skinning
+ * and material parameters.
+ *
+ * @author capdevon
+ */
 public class TestOgreConvert extends SimpleApplication {
 
-    public static void main(String[] args){
+    public static void main(String[] args) {
         TestOgreConvert app = new TestOgreConvert();
+        app.setPauseOnLostFocus(false);
         app.start();
     }
 
+    private final StringBuilder sb = new StringBuilder();
+    private int frameCount = 0;
+    private BitmapText bmp;
+    private Spatial spCopy;
+    private SkinningControl skinningControl;
+
     @Override
     public void simpleInitApp() {
-        Spatial ogreModel = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+        configureCamera();
+        setupLights();
+
+        bmp = createLabelText(10, 20, "<placeholder>");
+
+        // Load the Ogre model (Oto.mesh.xml) from the assets
+        Spatial model = assetManager.loadModel("Models/Oto/Oto.mesh.xml");
+        // Save the loaded model to jME3's binary format and then reload it.
+        // This tests the binary serialization/deserialization process.
+        spCopy = BinaryExporter.saveAndLoad(assetManager, model);
+        spCopy.setName("Oto-Copy");
+        rootNode.attachChild(spCopy);
+
+        AnimComposer animComposer = spCopy.getControl(AnimComposer.class);
+        animComposer.setCurrentAction("Walk");
+
+        // Get the SkinningControl from the model to inspect skinning properties
+        skinningControl = spCopy.getControl(SkinningControl.class);
+    }
+
+    private void setupLights() {
+        AmbientLight al = new AmbientLight();
+        rootNode.addLight(al);
 
         DirectionalLight dl = new DirectionalLight();
-        dl.setColor(ColorRGBA.White);
-        dl.setDirection(new Vector3f(0,-1,-1).normalizeLocal());
+        dl.setDirection(new Vector3f(0, -1, -1).normalizeLocal());
         rootNode.addLight(dl);
+    }
 
-        try {
-            ByteArrayOutputStream baos = new ByteArrayOutputStream();
-            BinaryExporter exp = new BinaryExporter();
-            exp.save(ogreModel, baos);
+    private void configureCamera() {
+        flyCam.setDragToRotate(true);
+        flyCam.setMoveSpeed(15f);
 
-            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
-            BinaryImporter imp = new BinaryImporter();
-            imp.setAssetManager(assetManager);
-            Node ogreModelReloaded = (Node) imp.load(bais, null, null);
+        cam.setLocation(new Vector3f(0, 0, 20));
+    }
 
-            AnimComposer composer = ogreModelReloaded.getControl(AnimComposer.class);
-            composer.setCurrentAction("Walk");
+    @Override
+    public void simpleUpdate(float tpf) {
+        frameCount++;
+        if (frameCount == 10) {
+            frameCount = 0;
 
-            rootNode.attachChild(ogreModelReloaded);
-        } catch (IOException ex){
-            ex.printStackTrace();
+            sb.append("HW Skinning Preferred: ").append(skinningControl.isHardwareSkinningPreferred()).append("\n");
+            sb.append("HW Skinning Enabled: ").append(skinningControl.isHardwareSkinningUsed()).append("\n");
+            sb.append("Mesh Targets: ").append(skinningControl.getTargets().length).append("\n");
+
+            for (MatParamOverride mpo : spCopy.getLocalMatParamOverrides()) {
+                sb.append(mpo.getVarType()).append(" ");
+                sb.append(mpo.getName()).append(": ");
+                sb.append(mpo.getValue()).append("\n");
+            }
+
+            bmp.setText(sb.toString());
+            sb.setLength(0);
         }
     }
+
+    private BitmapText createLabelText(int x, int y, String text) {
+        BitmapText bmp = new BitmapText(guiFont);
+        bmp.setText(text);
+        bmp.setLocalTranslation(x, settings.getHeight() - y, 0);
+        bmp.setColor(ColorRGBA.Red);
+        guiNode.attachChild(bmp);
+        return bmp;
+    }
 }

+ 18 - 2
jme3-lwjgl3/src/main/java/com/jme3/system/lwjgl/LwjglWindow.java

@@ -275,12 +275,20 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         );
 
         if (glfwPlatformSupported(GLFW_PLATFORM_WAYLAND)) {
-            
+
+            /*
+             * Change the platform GLFW uses to enable GLX on Wayland as long as you 
+             * have XWayland (X11 compatibility)
+             */
+            if (settings.isX11PlatformPreferred() && glfwPlatformSupported(GLFW_PLATFORM_X11)) {
+                glfwInitHint(GLFW_PLATFORM, GLFW_PLATFORM_X11);
+            }
+
             // Disables the libdecor bar when creating a fullscreen context
             // https://www.glfw.org/docs/latest/intro_guide.html#init_hints_wayland
             glfwInitHint(GLFW_WAYLAND_LIBDECOR, settings.isFullscreen() ? GLFW_WAYLAND_DISABLE_LIBDECOR : GLFW_WAYLAND_PREFER_LIBDECOR);
         }
-        
+
         if (!glfwInit()) {
             throw new IllegalStateException("Unable to initialize GLFW");
         }
@@ -404,6 +412,14 @@ public abstract class LwjglWindow extends LwjglContext implements Runnable {
         );
 
         int platformId = glfwGetPlatform();
+        if (settings.isX11PlatformPreferred()) {
+            if (platformId == GLFW_PLATFORM_X11) {
+                LOGGER.log(Level.INFO, "Active X11 server for GLX management:\n * Platform: GLFW_PLATFORM_X11|XWayland ({0})", platformId);
+            } else {
+                LOGGER.log(Level.WARNING, "Can't change platform to X11 (GLX), check if you have XWayland enabled");
+            }
+        }
+        
         if (platformId != GLFW_PLATFORM_WAYLAND && !settings.isFullscreen()) {
             /*
              * in case the window positioning hints above were ignored, but not

+ 9 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/App.java

@@ -36,6 +36,8 @@ import com.jme3.app.state.AppState;
 import com.jme3.app.state.VideoRecorderAppState;
 import com.jme3.math.ColorRGBA;
 
+import java.util.function.Consumer;
+
 /**
  * The app used for the tests. AppState(s) are used to inject the actual test code.
  * @author Richard Tingle (aka richtea)
@@ -46,10 +48,17 @@ public class App extends SimpleApplication {
         super(initialStates);
     }
 
+    Consumer<Throwable> onError = (onError) -> {};
+
     @Override
     public void simpleInitApp(){
         getViewPort().setBackgroundColor(ColorRGBA.Black);
         setTimer(new VideoRecorderAppState.IsoTimer(60));
     }
 
+    @Override
+    public void handleError(String errMsg, Throwable t) {
+        super.handleError(errMsg, t);
+        onError.accept(t);
+    }
 }

+ 10 - 4
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportExtension.java

@@ -50,7 +50,7 @@ import java.util.Optional;
  */
 public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallback, TestWatcher, BeforeTestExecutionCallback{
     private static ExtentReports extent;
-    private static final ThreadLocal<ExtentTest> test = new ThreadLocal<>();
+    private static ExtentTest currentTest;
 
     @Override
     public void beforeAll(ExtensionContext context) {
@@ -62,6 +62,8 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
             extent = new ExtentReports();
             extent.attachReporter(spark);
         }
+        // Initialize log capture to redirect console output to the report
+        ExtentReportLogCapture.initialize();
     }
 
     @Override
@@ -71,6 +73,9 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
         * anywhere else I can hook into the lifecycle of the end of all tests to write the report.
         */
         extent.flush();
+
+        // Restore the original System.out
+        ExtentReportLogCapture.restore();
     }
 
     @Override
@@ -96,10 +101,11 @@ public class ExtentReportExtension implements BeforeAllCallback, AfterAllCallbac
     @Override
     public void beforeTestExecution(ExtensionContext context) {
         String testName = context.getDisplayName();
-        test.set(extent.createTest(testName));
+        String className = context.getRequiredTestClass().getSimpleName();
+        currentTest = extent.createTest(className + "." + testName);
     }
 
     public static ExtentTest getCurrentTest() {
-        return test.get();
+        return currentTest;
     }
-}
+}

+ 117 - 0
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/ExtentReportLogCapture.java

@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2025 jMonkeyEngine
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ *   notice, this list of conditions and the following disclaimer.
+ *
+ * * Redistributions in binary form must reproduce the above copyright
+ *   notice, this list of conditions and the following disclaimer in the
+ *   documentation and/or other materials provided with the distribution.
+ *
+ * * Neither the name of 'jMonkeyEngine' nor the names of its contributors
+ *   may be used to endorse or promote products derived from this software
+ *   without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
+ * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+ * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+ * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package org.jmonkeyengine.screenshottests.testframework;
+
+import com.aventstack.extentreports.ExtentTest;
+
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+/**
+ * This class captures console logs and adds them to the ExtentReport.
+ * It redirects System.out to both the original console and the ExtentReport.
+ *
+ * @author Richard Tingle (aka richtea)
+ */
+public class ExtentReportLogCapture {
+
+    private static final PrintStream originalOut = System.out;
+    private static final PrintStream originalErr = System.err;
+    private static boolean initialized = false;
+
+    /**
+     * Initializes the log capture system. This should be called once at the start of the test suite.
+     */
+    public static void initialize() {
+        if (!initialized) {
+            // Redirect System.out and System.err
+            System.setOut(new ExtentReportPrintStream(originalOut));
+            System.setErr(new ExtentReportPrintStream(originalErr));
+
+            initialized = true;
+        }
+    }
+
+    /**
+     * Restores the original System.out. This should be called at the end of the test suite.
+     */
+    public static void restore() {
+        if(initialized) {
+            // Restore System.out and System.err
+            System.setOut(originalOut);
+            System.setErr(originalErr);
+            initialized = false;
+        }
+    }
+
+    /**
+     * A custom PrintStream that redirects output to both the original console and the ExtentReport.
+     */
+    private static class ExtentReportPrintStream extends PrintStream {
+        private StringBuilder buffer = new StringBuilder();
+
+        public ExtentReportPrintStream(OutputStream out) {
+            super(out, true);
+        }
+
+        @Override
+        public void write(byte[] buf, int off, int len) {
+            super.write(buf, off, len);
+
+            // Convert the byte array to a string and add to buffer
+            String s = new String(buf, off, len);
+            buffer.append(s);
+
+            // If we have a complete line (ends with newline), process it
+            if (s.endsWith("\n") || s.endsWith("\r\n")) {
+                String line = buffer.toString().trim();
+                if (!line.isEmpty()) {
+                    addToExtentReport(line);
+                }
+                buffer.setLength(0); // Clear the buffer
+            }
+        }
+
+        private void addToExtentReport(String s) {
+            try {
+                ExtentTest currentTest = ExtentReportExtension.getCurrentTest();
+                if (currentTest != null) {
+                    currentTest.info(s);
+                }
+            } catch (Exception e) {
+                // If there's an error adding to the report, just continue
+                // This ensures that console logs are still displayed even if there's an issue with the report
+            }
+        }
+    }
+
+}

+ 29 - 13
jme3-screenshot-tests/src/main/java/org/jmonkeyengine/screenshottests/testframework/TestDriver.java

@@ -57,8 +57,12 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.fail;
@@ -72,6 +76,8 @@ import static org.junit.jupiter.api.Assertions.fail;
  */
 public class TestDriver extends BaseAppState{
 
+    private static final Logger logger = Logger.getLogger(TestDriver.class.getName());
+
     public static final String IMAGES_ARE_DIFFERENT = "Images are different. (If you are running the test locally this is expected, images only reproducible on github CI infrastructure)";
 
     public static final String IMAGES_ARE_DIFFERENT_SIZES = "Images are different sizes.";
@@ -94,7 +100,7 @@ public class TestDriver extends BaseAppState{
 
     ScreenshotNoInputAppState screenshotAppState;
 
-    private final Object waitLock = new Object();
+    private CountDownLatch waitLatch;
 
     private final int tickToTerminateApp;
 
@@ -113,15 +119,19 @@ public class TestDriver extends BaseAppState{
         }
         if(tick >= tickToTerminateApp){
             getApplication().stop(true);
-            synchronized (waitLock) {
-                waitLock.notify(); // Release the wait
-            }
+            waitLatch.countDown();
         }
 
         tick++;
     }
 
-    @Override protected void initialize(Application app){}
+    @Override protected void initialize(Application app){
+        ((App)app).onError = error -> {
+            logger.log(Level.WARNING, "Error in test application", error);
+            waitLatch.countDown();
+        };
+
+    }
 
     @Override protected void cleanup(Application app){}
 
@@ -129,7 +139,6 @@ public class TestDriver extends BaseAppState{
 
     @Override protected void onDisable(){}
 
-
     /**
      * Boots up the application on a separate thread (blocks this thread) and then does the following:
      * - Takes screenshots on the requested frames
@@ -161,16 +170,23 @@ public class TestDriver extends BaseAppState{
         app.setSettings(appSettings);
         app.setShowSettings(false);
 
+        testDriver.waitLatch = new CountDownLatch(1);
         executor.execute(() -> app.start(JmeContext.Type.Display));
 
-        synchronized (testDriver.waitLock) {
-            try {
-                testDriver.waitLock.wait(10000); // Wait for the screenshot to be taken and application to stop
-                Thread.sleep(200); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
-            } catch (InterruptedException e) {
-                Thread.currentThread().interrupt();
-                throw new RuntimeException(e);
+        int maxWaitTimeMilliseconds = 45000;
+
+        try {
+            boolean exitedProperly = testDriver.waitLatch.await(maxWaitTimeMilliseconds, TimeUnit.MILLISECONDS);
+
+            if(!exitedProperly){
+                logger.warning("Test driver did not exit in " + maxWaitTimeMilliseconds + "ms. Timed out");
+                app.stop(true);
             }
+
+            Thread.sleep(1000); //give time for openGL is fully released before starting a new test (get random JVM crashes without this)
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
         }
 
         //search the imageTempDir

BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_localSpace_f45.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.effects.TestIssue1773.testIssue1773_worldSpace_f45.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_DefaultDirectionalLight_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_HighRoughness_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_LowRoughness_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRLighting.testPBRLighting_UpdatedDirectionalLight_f12.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithRealtimeBaking_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.light.pbr.TestPBRSimple.testPBRSimple_WithoutRealtimeBaking_f10.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromAbove_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromFront_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.model.shape.TestBillboard.testBillboard_fromRight_f1.png


BIN
jme3-screenshot-tests/src/test/resources/org.jmonkeyengine.screenshottests.post.TestFog.testFog_f1.png