Atmosphäre-Tools, EZ-Tree-Fixes, i18n, AnimSet, Baum-Export

- blight-lang: TextResolver + EN/DE Sprachpakete (TextReference i18n)
- AnimSet: Clips + ActionMap in .animset.json zusammengeführt
- EZ-Tree: Branch-Parameter-Fixes (length/radius/children/force nicht senden,
  twist Grad→Radiant, leaves.size ×5); Ordner-ComboBox mit Auto-Refresh
- Logging beim Baum-Export in allen drei Generatoren (EZ-Tree, Blight, Palme)
- Atmosphäre-Tools: Emitter, Licht, Wasser, Sound-/Musikbereiche, Spiel-Starten
- AnimPreviewState, RetargetingSystem, AnimationLibrary (Animations-Editor)
- Terrain-Transparenz-Fix, Schatten-Fix, ThirdPersonCamera-Fix
- DayNightState, WeatherState, CloudsNode, JmeConsole
- MapIO v6, neue blight-common Modell-Klassen (GameCharacter, NPC, Quests…)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 23:12:46 +02:00
parent 1e0789461f
commit 50f496c864
121 changed files with 13277 additions and 806 deletions

View File

@@ -10,7 +10,7 @@ javafx {
}
application {
mainClass = 'de.blight.editor.EditorLauncher'
mainClass = 'de.blight.editor.BlightEditor'
applicationDefaultJvmArgs = [
'--add-opens', 'java.base/java.lang=ALL-UNNAMED',
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
@@ -36,7 +36,13 @@ dependencies {
implementation "org.jmonkeyengine:jme3-lwjgl3:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-terrain:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-effects:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-plugins:${jmeVersion}"
implementation "org.jmonkeyengine:jme3-testdata:${jmeVersion}"
implementation 'com.google.code.gson:gson:2.11.0'
implementation 'org.slf4j:slf4j-api:2.0.17'
implementation 'org.slf4j:jul-to-slf4j:2.0.17'
compileOnly 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
}
tasks.register('extractNatives', Copy) {
@@ -65,6 +71,6 @@ run {
jar {
manifest {
attributes 'Main-Class': application.mainClass
attributes 'Main-Class': application.mainClass.get()
}
}

View File

@@ -1,11 +1,16 @@
package de.blight.editor;
import org.slf4j.bridge.SLF4JBridgeHandler;
/**
* Separater Launcher-Einstiegspunkt, damit der JavaFX-Klassenpfad korrekt
* aufgelöst wird, bevor Application.launch() aufgerufen wird.
*/
public class EditorLauncher {
public class BlightEditor {
public static void main(String[] args) {
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
// ProjectRoot muss als erstes initialisiert werden, damit alle
// relativen Pfade korrekt aufgelöst werden (auch bei IDE-Start mit
// workingDir = blight-editor/ statt Projekt-Root).

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +1,222 @@
package de.blight.editor;
import com.jme3.app.SimpleApplication;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeContext;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.state.EzTreeState;
import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import javafx.scene.image.WritableImage;
public class JmeEditorApp extends SimpleApplication {
private final SharedInput input;
private final WritableImage jfxImage;
private final int vpWidth;
private final int vpHeight;
public JmeEditorApp(SharedInput input, WritableImage jfxImage, int vpWidth, int vpHeight) {
this.input = input;
this.jfxImage = jfxImage;
this.vpWidth = vpWidth;
this.vpHeight = vpHeight;
}
/** Startet JME3 in einem Daemon-Thread (blockierend bis App endet). */
public static JmeEditorApp launch(SharedInput input, WritableImage jfxImage,
int vpWidth, int vpHeight) {
JmeEditorApp app = new JmeEditorApp(input, jfxImage, vpWidth, vpHeight);
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight Editor JME3");
settings.setResolution(vpWidth, vpHeight);
settings.setRenderer(AppSettings.LWJGL_OPENGL32);
settings.setAudioRenderer(null);
settings.setSamples(1);
app.setSettings(settings);
app.setShowSettings(false);
app.setPauseOnLostFocus(false);
Thread t = new Thread(() -> app.start(JmeContext.Type.OffscreenSurface), "jme3-editor");
t.setDaemon(true);
t.start();
return app;
}
@Override
public void simpleInitApp() {
flyCam.setEnabled(false);
// editor-assets/ im AssetManager registrieren, damit Texturen und Modelle
// aus diesem Verzeichnis geladen werden können (relativ zum Arbeitsverzeichnis).
try {
assetManager.registerLocator(
ProjectRoot.resolve("editor-assets").toAbsolutePath().toString(),
FileLocator.class);
} catch (Exception ignored) {}
// Texture2D-Attachment: readFrameBuffer() funktioniert nur mit Texture, nicht Renderbuffer
Texture2D colorTex = new Texture2D(vpWidth, vpHeight, Image.Format.RGBA8);
FrameBuffer fb = new FrameBuffer(vpWidth, vpHeight, 1);
fb.setDepthBuffer(Image.Format.Depth);
fb.setColorTexture(colorTex);
viewPort.setOutputFrameBuffer(fb);
// Frame-Export in das JavaFX-WritableImage
viewPort.addProcessor(new FrameTransfer(jfxImage));
stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input));
stateManager.attach(new EzTreeState(input));
stateManager.attach(new PalmGeneratorState(input));
}
@Override
public void simpleUpdate(float tpf) {
com.jme3.math.Vector3f loc = cam.getLocation();
com.jme3.math.Vector3f dir = cam.getDirection();
input.camX = loc.x;
input.camY = loc.y;
input.camZ = loc.z;
input.camYaw = (float) Math.toDegrees(Math.atan2(-dir.x, -dir.z));
input.camPitch = (float) Math.toDegrees(
Math.asin(Math.max(-1f, Math.min(1f, dir.y))));
String cmd = input.pendingCommand;
if (cmd != null) {
input.pendingCommand = null;
processCommand(cmd);
}
}
private void processCommand(String raw) {
String[] parts = raw.trim().split("\\s+");
switch (parts[0].toLowerCase()) {
case "goto" -> {
try {
if (parts.length >= 4) {
// goto x y z — direkte Koordinaten
float x = Float.parseFloat(parts[1]);
float y = Float.parseFloat(parts[2]);
float z = Float.parseFloat(parts[3]);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
input.consoleOutput = "Goto" + x + " / " + y + " / " + z;
} else if (parts.length >= 3) {
// goto x z — Bodenabstand beibehalten
float x = Float.parseFloat(parts[1]);
float z = Float.parseFloat(parts[2]);
TerrainEditorState tes =
stateManager.getState(TerrainEditorState.class);
float srcGround = tes != null
? tes.getTerrainHeightAt(cam.getLocation().x,
cam.getLocation().z)
: 0f;
float heightAboveGround = cam.getLocation().y - srcGround;
float dstGround = tes != null
? tes.getTerrainHeightAt(x, z)
: 0f;
float y = dstGround + heightAboveGround;
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
input.consoleOutput = "Goto → X=" + x + " Z=" + z
+ " (Y=" + String.format("%.1f", y) + ")";
} else {
input.consoleOutput = "Syntax: goto <x> <z> oder goto <x> <y> <z>";
}
} catch (NumberFormatException e) {
input.consoleOutput = "Fehler: Koordinaten müssen Zahlen sein";
}
}
case "help" -> input.consoleOutput =
"Befehle: goto <x> <z> | goto <x> <y> <z> | help";
default ->
input.consoleOutput = "Unbekannter Befehl: " + parts[0];
}
}
}
package de.blight.editor;
import com.jme3.app.SimpleApplication;
import com.jme3.asset.plugins.FileLocator;
import com.jme3.system.AppSettings;
import com.jme3.system.JmeContext;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.state.AnimPreviewState;
import de.blight.editor.state.EmitterState;
import de.blight.editor.state.MusicAreaState;
import de.blight.editor.state.PlayToolState;
import de.blight.editor.state.SoundAreaState;
import de.blight.editor.state.WaterBodyState;
import de.blight.editor.state.EzTreeState;
import de.blight.editor.state.LightState;
import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import de.blight.game.console.JmeConsole;
import de.blight.game.state.DayNightState;
import javafx.scene.image.WritableImage;
public class JmeEditorApp extends SimpleApplication {
private final SharedInput input;
private final WritableImage initialImage;
private final int vpWidth;
private final int vpHeight;
private JmeConsole jmeConsole;
private FrameTransfer frameTransfer;
private int currentW;
private int currentH;
public JmeEditorApp(SharedInput input, WritableImage jfxImage, int vpWidth, int vpHeight) {
this.input = input;
this.initialImage = jfxImage;
this.vpWidth = vpWidth;
this.vpHeight = vpHeight;
}
/** Startet JME3 in einem Daemon-Thread (blockierend bis App endet). */
public static JmeEditorApp launch(SharedInput input, WritableImage jfxImage,
int vpWidth, int vpHeight) {
JmeEditorApp app = new JmeEditorApp(input, jfxImage, vpWidth, vpHeight);
AppSettings settings = new AppSettings(true);
settings.setTitle("Blight Editor JME3");
settings.setResolution(vpWidth, vpHeight);
settings.setRenderer(AppSettings.LWJGL_OPENGL32);
settings.setAudioRenderer(null);
settings.setSamples(1);
app.setSettings(settings);
app.setShowSettings(false);
app.setPauseOnLostFocus(false);
Thread t = new Thread(() -> app.start(JmeContext.Type.OffscreenSurface), "jme3-editor");
t.setDaemon(true);
t.start();
return app;
}
@Override
public void simpleInitApp() {
flyCam.setEnabled(false);
// Explizit registrieren, falls General.cfg die Klassen beim ersten Start
// noch nicht gefunden hat (jme3-plugins war zuvor nicht auf dem Classpath).
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GltfLoader.class, "gltf");
assetManager.registerLoader(com.jme3.scene.plugins.gltf.GlbLoader.class, "glb");
java.nio.file.Path blightAssets = ProjectRoot.resolve("blight-assets", "src", "main", "resources");
if (java.nio.file.Files.isDirectory(blightAssets)) {
assetManager.registerLocator(blightAssets.toAbsolutePath().toString(), FileLocator.class);
}
currentW = vpWidth;
currentH = vpHeight;
buildFrameBuffer(vpWidth, vpHeight, initialImage);
stateManager.attach(new SceneObjectState(input));
stateManager.attach(new TerrainEditorState(input));
stateManager.attach(new TreeGeneratorState(input));
stateManager.attach(new EzTreeState(input));
stateManager.attach(new PalmGeneratorState(input));
stateManager.attach(new LightState(input));
stateManager.attach(new EmitterState(input));
stateManager.attach(new WaterBodyState(input));
stateManager.attach(new SoundAreaState(input));
stateManager.attach(new MusicAreaState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new AnimPreviewState(input));
// JME-Konsole (Editor-Modus: kein RawInputListener Eingabe via SharedInput)
jmeConsole = new JmeConsole(false);
registerEditorCommands();
jmeConsole.setOnVisibilityChanged(open -> {
input.consoleIsOpen = open;
if (!open) input.consoleChars.clear();
});
stateManager.attach(jmeConsole);
}
private void registerEditorCommands() {
jmeConsole.registerCommand("goto", args -> {
try {
if (args.length >= 4) {
float x = Float.parseFloat(args[1]);
float y = Float.parseFloat(args[2]);
float z = Float.parseFloat(args[3]);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
return String.format("Goto → %.1f / %.1f / %.1f", x, y, z);
} else if (args.length >= 3) {
float x = Float.parseFloat(args[1]);
float z = Float.parseFloat(args[2]);
TerrainEditorState tes = stateManager.getState(TerrainEditorState.class);
float srcH = tes != null ? tes.getTerrainHeightAt(cam.getLocation().x, cam.getLocation().z) : 0f;
float dstH = tes != null ? tes.getTerrainHeightAt(x, z) : 0f;
float y = dstH + (cam.getLocation().y - srcH);
cam.setLocation(new com.jme3.math.Vector3f(x, y, z));
return String.format("Goto → X=%.1f Z=%.1f (Y=%.1f)", x, z, y);
}
return "Syntax: goto <x> <z> oder goto <x> <y> <z>";
} catch (NumberFormatException e) {
return "Fehler: Koordinaten müssen Zahlen sein";
}
});
jmeConsole.registerCommand("time", args -> {
if (args.length < 2) return "Syntax: time <024> (0 = Mitternacht, 12 = Mittag)";
try {
float hours = Float.parseFloat(args[1]);
if (hours < 0 || hours > 24) return "Fehler: Wert zwischen 0 und 24";
DayNightState dns = stateManager.getState(DayNightState.class);
if (dns == null) return "Tag/Nacht-System nicht aktiv";
dns.getDayTime().setTimeOfDay(hours / 24f);
int h = (int) hours;
int m = (int)((hours - h) * 60f);
return String.format("Zeit gesetzt: %02d:%02d Uhr", h, m);
} catch (NumberFormatException e) {
return "Fehler: Zahl erwartet";
}
});
}
// ── Framebuffer-Verwaltung ────────────────────────────────────────────────
private void buildFrameBuffer(int w, int h, WritableImage image) {
Texture2D colorTex = new Texture2D(w, h, Image.Format.RGBA8);
FrameBuffer fb = new FrameBuffer(w, h, 1);
fb.setDepthBuffer(Image.Format.Depth);
fb.setColorTexture(colorTex);
viewPort.setOutputFrameBuffer(fb);
guiViewPort.setOutputFrameBuffer(fb);
frameTransfer = new FrameTransfer(image);
guiViewPort.addProcessor(frameTransfer);
}
private void resizeViewport(int newW, int newH) {
guiViewPort.removeProcessor(frameTransfer);
cam.resize(newW, newH, true);
guiViewPort.getCamera().resize(newW, newH, false);
WritableImage newImage = new WritableImage(newW, newH);
buildFrameBuffer(newW, newH, newImage);
if (jmeConsole != null) jmeConsole.rebuild();
input.resizedImage.set(newImage);
currentW = newW;
currentH = newH;
}
@Override
public void simpleUpdate(float tpf) {
// Viewport-Resize (von JavaFX angefordert)
int[] req = input.resizeRequest.getAndSet(null);
if (req != null && req[0] > 0 && req[1] > 0
&& (req[0] != currentW || req[1] != currentH)) {
resizeViewport(req[0], req[1]);
}
com.jme3.math.Vector3f loc = cam.getLocation();
com.jme3.math.Vector3f dir = cam.getDirection();
input.camX = loc.x;
input.camY = loc.y;
input.camZ = loc.z;
input.camYaw = (float) Math.toDegrees(Math.atan2(-dir.x, -dir.z));
input.camPitch = (float) Math.toDegrees(
Math.asin(Math.max(-1f, Math.min(1f, dir.y))));
if (jmeConsole == null) return;
// Toggle-Signal von JavaFX
if (input.consoleToggle) {
input.consoleToggle = false;
jmeConsole.toggle();
}
if (!jmeConsole.isOpen()) return;
// Zeichen-Eingabe
Character c;
while ((c = input.consoleChars.poll()) != null) jmeConsole.feedChar(c);
// Sondertasten
Integer key;
while ((key = input.consoleKeys.poll()) != null) {
switch (key) {
case 8 -> jmeConsole.feedBackspace();
case 10 -> jmeConsole.feedEnter();
case 27 -> jmeConsole.feedEscape();
}
}
}
}

View File

@@ -12,6 +12,7 @@ import javafx.scene.image.WritableImage;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/** Thread-safe Brücke: JavaFX-Events → JME3-Update-Schleife. */
public class SharedInput {
@@ -19,18 +20,27 @@ public class SharedInput {
// ── Aktive Tools ─────────────────────────────────────────────────────────
public final HeightTool heightTool = new HeightTool();
public final UpperHeightTool upperHeightTool = new UpperHeightTool();
public final HoleTool holeTool = new HoleTool();
public final GrassTool grassTool = new GrassTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public volatile EditorTool activeTool = heightTool;
// ── Aktive Ebene: 0=Basis-Terrain, 1=Gebirge, 2=Höhlen, 3=Gras, 4=Textur ──
// ── Aktive Ebene: 0=Basis-Terrain, 3=Gras, 4=Textur ─────────────────────
public volatile int activeLayer = 0;
// ── Upper-Layer-Sichtbarkeit ─────────────────────────────────────────────
public volatile boolean upperLayerVisible = true;
// ── Kamerabewegung (WASD + QE) ──────────────────────────────────────────
public volatile boolean forward, backward, left, right, up, down;
// ── Mausrad (JavaFX akkumuliert, JME konsumiert einmal pro Frame) ────────
public final java.util.concurrent.atomic.AtomicInteger scrollAccum =
new java.util.concurrent.atomic.AtomicInteger();
// ── Shift: horizontale Bewegung (Y-Höhe wird beibehalten) ───────────────
public volatile boolean shiftHeld;
// ── Kamerarotation (Maus-Drag mit mittlerer Taste) ───────────────────────
private final AtomicInteger mouseDxAccum = new AtomicInteger();
private final AtomicInteger mouseDyAccum = new AtomicInteger();
@@ -48,10 +58,6 @@ public class SharedInput {
public record TerrainEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TerrainEdit> editQueue = new ConcurrentLinkedQueue<>();
// ── Upper-Layer-Edits ─────────────────────────────────────────────────────
public record UpperLayerEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<UpperLayerEdit> upperLayerEditQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Edits ────────────────────────────────────────────────────────────
/** action +1 = Dichte erhöhen (Linksklick), -1 = Dichte verringern (Rechtsklick). */
public record GrassEdit(float screenX, float screenY, int action) {}
@@ -62,10 +68,35 @@ public class SharedInput {
public record TextureEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<TextureEdit> textureEditQueue = new ConcurrentLinkedQueue<>();
// ── Textur-Konfiguration (JavaFX → JME3) ─────────────────────────────────
/** Pfade der 4 Terrain-Textur-Slots ("" = Standard). JFX schreibt neue Referenz, JME liest. */
public volatile String[] terrainTexturePaths = new String[]{"", "", "", ""};
/** JFX setzt true, JME liest + resettet. */
public volatile boolean terrainTexturesChanged = false;
/** Normal-Map-Pfade der 4 Terrain-Slots ("" = keine). */
public volatile String[] terrainNormalMapPaths = new String[]{"", "", "", ""};
public volatile boolean terrainNormalMapsChanged = false;
/** Pfade der 4 Gebirge-Textur-Slots ("" = Standard). */
public volatile String[] upperTexturePaths = new String[]{"", "", "", ""};
public volatile boolean upperTexturesChanged = false;
/** Normal-Map-Pfade der 4 Gebirge-Slots ("" = keine). */
public volatile String[] upperNormalMapPaths = new String[]{"", "", "", ""};
public volatile boolean upperNormalMapsChanged = false;
/** JME setzt true nach Map-Load → JFX kann Textur-UI aktualisieren. */
public volatile boolean texturePathsLoaded = false;
// ── Viewport-Skalierung (JavaFX-Pixel → JME3-Pixel) ─────────────────────
public volatile double viewportScaleX = 1.0;
public volatile double viewportScaleY = 1.0;
// ── Viewport-Resize (JavaFX → JME → JavaFX) ──────────────────────────────
/** JavaFX setzt neue Zielgröße; JME liest einmalig per getAndSet(null). */
public final AtomicReference<int[]> resizeRequest = new AtomicReference<>();
/** JME setzt fertiges WritableImage nach Resize; JavaFX liest per getAndSet(null). */
public final AtomicReference<WritableImage> resizedImage = new AtomicReference<>();
// ── Mausposition im Viewport (JavaFX-Pixel, -1 = außerhalb) ─────────────
public volatile float mouseScreenX = -1f;
public volatile float mouseScreenY = -1f;
@@ -82,7 +113,8 @@ public class SharedInput {
public volatile int treePreviewW = 1024;
public volatile int treePreviewH = 1024;
public volatile String treeGenStatusMsg = null;
public volatile boolean refreshAssets = false;
public volatile boolean refreshAssets = false;
public volatile boolean refreshTreeFolders = false;
/**
* Aktuelles Vorschau-Bild. JME3 ersetzt die Referenz bei Größenänderung;
* treePreviewResized signalisiert JavaFX, die ImageView zu aktualisieren.
@@ -95,7 +127,7 @@ public class SharedInput {
public final ConcurrentLinkedQueue<TreeGenRequest> treeGenQueue = new ConcurrentLinkedQueue<>();
// ── EZ-Tree-Generator ─────────────────────────────────────────────────────
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, boolean exportAfter, String exportName) {}
public record EzTreeGenRequest(de.blight.eztree.TreeOptions options, String presetName, boolean exportAfter, String exportName, String treeCategory) {}
public final ConcurrentLinkedQueue<EzTreeGenRequest> ezTreeGenQueue = new ConcurrentLinkedQueue<>();
// ── Palmen-Generator ──────────────────────────────────────────────────────
@@ -107,9 +139,27 @@ public class SharedInput {
public static final int LAYER_OBJECTS = 5;
/** activeLayer==6 → Objekte bearbeiten (Selektion + Gizmo) */
public static final int LAYER_OBJECTS_EDIT = 6;
/** activeLayer==7 → Lichtquellen platzieren und bearbeiten */
public static final int LAYER_LIGHTS = 7;
// Selektionsmodi (LAYER_OBJECTS_EDIT)
public static final int SEL_MODE_OBJECT = 0;
public static final int SEL_MODE_POLYGON = 1; // ganze Geometry (war SEL_MODE_FACE)
public static final int SEL_MODE_EDGE = 2;
public static final int SEL_MODE_VERTEX = 3; // einzelner Punkt
// Bearbeitungswerkzeuge (LAYER_OBJECTS_EDIT)
public static final int EDIT_TOOL_MOVE = 0;
public static final int EDIT_TOOL_ROTATE = 1;
public static final int EDIT_TOOL_SCALE = 2;
/** JavaFX → JME3: Aktiver Selektionsmodus. */
public volatile int objectSelectionMode = SEL_MODE_OBJECT;
/** JavaFX → JME3: Aktives Bearbeitungswerkzeug. */
public volatile int objectEditTool = EDIT_TOOL_MOVE;
/** Klick im Viewport: Objekt auswählen oder am Terrain-Treffpunkt platzieren. */
public record ObjectClick(float screenX, float screenY, boolean rightButton) {}
public record ObjectClick(float screenX, float screenY, boolean rightButton, boolean shift) {}
public final ConcurrentLinkedQueue<ObjectClick> objectClickQueue = new ConcurrentLinkedQueue<>();
/**
@@ -120,7 +170,9 @@ public class SharedInput {
public final ConcurrentLinkedQueue<ObjectDrag> objectDragQueue = new ConcurrentLinkedQueue<>();
/** Wird von JME3 gesetzt wenn ein neues Objekt oder eine neue Selektion vorliegt. */
public volatile String selectedObjectInfo = null; // "modelPath|solid|x|y|z|rotY|scale"
// Format: "1|modelPath|solid|x|y|z|rotX|rotY|rotZ|scale|texPath" (1 Objekt)
// "N" (N≥2 Objekte ausgewählt)
public volatile String selectedObjectInfo = null;
public volatile boolean objectSelectionChanged = false;
/** Wird von JME3 gesetzt, wenn ein Objekt gerade neu platziert wurde (nicht nur selektiert). */
public volatile boolean objectJustPlaced = false;
@@ -128,9 +180,41 @@ public class SharedInput {
/** JavaFX → JME3: Modell-Pfad für nächste Platzierung (relativ zu editor-assets/). */
public volatile String pendingModelPath = null;
/** Wenn gesetzt: Baum-Ordner-Platzierungsmodus. Relativ zu blight-assets/src/main/resources. */
public volatile String treeFolderPath = null;
/** Status-Meldung aus JME: welcher Baum gerade ausgewählt ist. */
public volatile String randomTreeStatus = "";
/** JavaFX → JME3: Textur-Pfad für nächste Platzierung (relativ zu editor-assets/, "" = keine). */
public volatile String pendingTexturePath = "";
/** JavaFX → JME3: Normal-Map-Pfad für nächste Platzierung ("" = keine). */
public volatile String pendingNormalMapPath = "";
/** JavaFX → JME3: Solid-Flag des selektierten Objekts ändern. */
public volatile Boolean pendingSolidChange = null;
/** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */
public volatile String pendingAnimClip = null;
// Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen
public record ObjectPropertyChange(
float x, float y, float z,
float rotX, float rotY, float rotZ,
boolean solid,
String texPath, // null = nicht ändern
String normalMapPath, // null = nicht ändern
String matPath // null = nicht ändern
) {}
public final ConcurrentLinkedQueue<ObjectPropertyChange> objectPropertyQueue =
new ConcurrentLinkedQueue<>();
// Selektion zusammenfassen (JavaFX → JME)
public volatile boolean mergeSelectedRequested = false;
// Selektion löschen (JavaFX → JME)
public volatile boolean deleteSelectedRequested = false;
// Als Vorlage speichern: Name (JavaFX → JME, null = kein Auftrag)
public volatile String saveAsTemplateRequest = null;
// ── Mesh-Erstellung ───────────────────────────────────────────────────────
/**
* Form: "Box" | "Kugel" | "Zylinder" | "Ebene"
@@ -159,21 +243,225 @@ public class SharedInput {
/** Pitch in Grad: positiv = Blick nach oben, negativ = nach unten. */
public volatile float camPitch = 0f;
// ── Konsole (JavaFX → JME3 und zurück) ──────────────────────────────────
/** Befehl, der beim nächsten JME3-Update ausgeführt werden soll. */
public volatile String pendingCommand = null;
/** Antworttext, den JME3 nach der Befehlsausführung setzt. */
// ── JME-Konsole: JavaFX → JME-Thread ────────────────────────────────────
/** Toggle-Signal: JavaFX setzt true, JME liest und setzt zurück. */
public volatile boolean consoleToggle = false;
/** Zeichen-Eingabe (druckbare Zeichen ≥ 0x20). */
public final ConcurrentLinkedQueue<Character> consoleChars = new ConcurrentLinkedQueue<>();
/** Sondertasten: 8=Backspace, 10=Enter, 27=Escape. */
public final ConcurrentLinkedQueue<Integer> consoleKeys = new ConcurrentLinkedQueue<>();
/** JME setzt diesen Wert; JavaFX liest ihn (z. B. für Eingabesperre). */
public volatile boolean consoleIsOpen = false;
/** @deprecated Nur noch für Rückwärtskompatibilität nicht mehr verwenden. */
@Deprecated public volatile String pendingCommand = null;
/** Status-/Fehlermeldungen von JME an JavaFX-Statusleiste. */
public volatile String consoleOutput = null;
// ── Vertex-Snap ───────────────────────────────────────────────────────────
/** Wenn true: Punkte, die beim Ziehen in Snap-Radius geraten, verschmelzen. */
public volatile boolean vertexSnapEnabled = false;
public volatile float vertexSnapRadius = 0.5f;
/** JavaFX setzt true beim Loslassen der Maustaste → JME3 führt Snap aus. */
public volatile boolean vertexSnapTrigger = false;
// ── Licht-Werkzeug ────────────────────────────────────────────────────────
/** Klick im Viewport im Licht-Modus: platzieren oder selektieren. */
public record LightClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<LightClick> lightClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Lichts. Format: "idx|x|y|z|r|g|b|intensity|radius" oder null. */
public volatile String selectedLightInfo = null;
public volatile boolean lightSelectionChanged = false;
/** JavaFX → JME: Eigenschaftsänderung des selektierten Lichts. */
public record LightPropertyChange(float r, float g, float b, float intensity, float radius) {}
public final AtomicReference<LightPropertyChange> pendingLightProp = new AtomicReference<>();
/** JavaFX → JME: Selektiertes Licht löschen. */
public volatile boolean deleteLightRequested = false;
// ── Emitter-Werkzeug ──────────────────────────────────────────────────────
/** activeLayer==8 → Partikel-Emitter platzieren und bearbeiten */
public static final int LAYER_EMITTERS = 8;
/** Klick im Viewport im Emitter-Modus: platzieren oder selektieren. */
public record EmitterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<EmitterClick> emitterClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Info des selektierten Emitters.
* Format: "idx|x|y|z|activationRadius|texturePath|imagesX|imagesY|
* startR|startG|startB|startA|endR|endG|endB|endA|
* startSize|endSize|velX|velY|velZ|velVar|
* gravX|gravY|gravZ|lowLife|highLife|maxParticles|emitRate"
* oder null wenn nichts gewählt.
*/
public volatile String selectedEmitterInfo = null;
public volatile boolean emitterSelectionChanged = false;
/** JavaFX → JME: vollständige aktualisierte Parameter des selektierten Emitters. */
public final AtomicReference<de.blight.common.PlacedEmitter> pendingEmitter = new AtomicReference<>();
/** JavaFX → JME: Selektierten Emitter löschen. */
public volatile boolean deleteEmitterRequested = false;
/** JavaFX → JME: Preset für nächsten neu platzierten Emitter (0=Feuer,1=Rauch,2=Funken). */
public volatile int emitterPreset = 0;
// ── Wasser-Werkzeug ───────────────────────────────────────────────────────
/** activeLayer==9 → Wasseroberflächen platzieren und bearbeiten */
public static final int LAYER_WATER = 9;
// ── Sound-Bereiche ────────────────────────────────────────────────────────
/** activeLayer==10 → Sound-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_SOUND_AREAS = 10;
// ── Musik-Bereiche ────────────────────────────────────────────────────────
/** activeLayer==11 → Musik-Bereiche (Polygon) platzieren und bearbeiten */
public static final int LAYER_MUSIC_AREAS = 11;
// ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** activeLayer==12 → Temporären Spawnpunkt setzen und Spiel starten */
public static final int LAYER_PLAY_TOOL = 12;
/** Klick im Viewport im Wasser-Modus: platzieren oder selektieren. */
public record WaterClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<WaterClick> waterClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Info der selektierten Wasseroberfläche.
* Format: "idx|x|y|z|width|depth" oder null.
*/
public volatile String selectedWaterInfo = null;
public volatile boolean waterSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter der selektierten Wasseroberfläche. */
public final AtomicReference<de.blight.common.PlacedWater> pendingWater = new AtomicReference<>();
/** JavaFX → JME: Selektierte Wasseroberfläche löschen. */
public volatile boolean deleteWaterRequested = false;
// ── Sound-Bereich-Werkzeug ────────────────────────────────────────────────
public record SoundAreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<SoundAreaClick> soundAreaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Sound-Bereichs. Format: "idx|soundPath|volume|crossfade" oder null. */
public volatile String selectedSoundAreaInfo = null;
public volatile boolean soundAreaSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter des selektierten Sound-Bereichs. */
public final AtomicReference<de.blight.common.PlacedSoundArea> pendingSoundArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Sound-Bereich löschen. */
public volatile boolean deleteSoundAreaRequested = false;
// ── Musik-Bereich-Werkzeug ────────────────────────────────────────────────
public record MusicAreaClick(float screenX, float screenY, boolean rightButton) {}
public final ConcurrentLinkedQueue<MusicAreaClick> musicAreaClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JavaFX: Info des selektierten Musik-Bereichs. Format: "idx|dayTrack|nightTrack|combatTrack" oder null. */
public volatile String selectedMusicAreaInfo = null;
public volatile boolean musicAreaSelectionChanged = false;
/** JavaFX → JME: aktualisierte Parameter des selektierten Musik-Bereichs. */
public final AtomicReference<de.blight.common.PlacedMusicArea> pendingMusicArea = new AtomicReference<>();
/** JavaFX → JME: Selektierten Musik-Bereich löschen. */
public volatile boolean deleteMusicAreaRequested = false;
/** JavaFX → JME: Laufendes Polygon-Zeichnen abbrechen (ESC). */
public volatile boolean cancelZoneDrawing = false;
// ── Spiel-Starten-Werkzeug ────────────────────────────────────────────────
/** Klick im Viewport zum Setzen des temporären Spawnpunkts. */
public record PlayToolClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<PlayToolClick> playToolClickQueue = new ConcurrentLinkedQueue<>();
/**
* JME → JavaFX: Terrain-Treffpunkt nach Spawn-Klick.
* Format: "x|z" oder null.
*/
public volatile String pickedSpawnInfo = null;
public volatile boolean spawnPickChanged = false;
/** Temporärer Spawnpunkt (NaN = nicht gesetzt). Wird beim Spielstart als System-Property übergeben. */
public volatile float tempSpawnX = Float.NaN;
public volatile float tempSpawnZ = Float.NaN;
// ── Animations-Vorschau ──────────────────────────────────────────────────
public volatile float animPreviewRotY = 0f;
public volatile float animPreviewRotX = 25f;
public volatile float animPreviewZoom = 1.0f;
public volatile float animPreviewSpeed = 1.0f;
public volatile int animPreviewW = 512;
public volatile int animPreviewH = 512;
public volatile WritableImage animPreviewImage = new WritableImage(512, 512);
public volatile boolean animPreviewResized = false;
/** JavaFX → JME3: Modell laden (relativer Asset-Pfad). null = kein Auftrag. */
public volatile String animPreviewLoadPath = null;
/** JavaFX → JME3: Clip abspielen; "" = Stop. null = kein Auftrag. */
public volatile String animPreviewPlayClip = null;
/** JavaFX → JME3: Animation-j3o-Pfad zum Retargeting + Hinzufügen. null = kein Auftrag. */
public volatile String animPreviewAddAnimPath = null;
/** JavaFX → JME3: Clip-Name zum Entfernen aus dem geladenen Modell. null = kein Auftrag. */
public volatile String animPreviewRemoveClip = null;
/** JavaFX → JME3: Scan aller j3o auf Skelett-Controls anstoßen. */
public volatile boolean scanSkeletalRequested = false;
public volatile boolean animDumpRequested = false;
/** JME3 → JavaFX: Relative Pfade (Assets-Root) aller j3o mit Skelett; getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.Set<String>>
skeletalPaths = new java.util.concurrent.atomic.AtomicReference<>();
public volatile boolean animPreviewLoop = true;
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
public volatile String animPreviewStatus = null;
/** JME3 → JavaFX: Clip-Namen nach Modell-Laden. getAndSet(null) konsumiert. */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewClips = new java.util.concurrent.atomic.AtomicReference<>();
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
* Wird von JavaFX benötigt, um die zugehörige .animset.json-Datei zu finden.
* getAndSet(null) konsumiert.
*/
public final java.util.concurrent.atomic.AtomicReference<String>
animPreviewLoadedPath = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animations-Clip umbenennen ────────────────────────────────────────────
public record ClipRenameRequest(String oldName, String newName) {}
public final java.util.concurrent.atomic.AtomicReference<ClipRenameRequest>
clipRenameRequest = new java.util.concurrent.atomic.AtomicReference<>();
// ── Animations-Set speichern ──────────────────────────────────────────────
public record AnimSetSaveRequest(java.util.List<String> clips, String setName, java.util.Map<String, String> actionMap) {}
public final java.util.concurrent.atomic.AtomicReference<AnimSetSaveRequest>
animSetSaveRequest = new java.util.concurrent.atomic.AtomicReference<>();
/** JME3 → JavaFX: Status-Meldung für Clip- und Set-Operationen. */
public volatile String animOpStatus = null;
// ── Modell-Konvertierung ──────────────────────────────────────────────────
/**
* Konvertiert ein natives Modell (OBJ/GLTF/…) zu .j3o.
* assetPath : Pfad relativ zu editor-assets/ (z. B. "models/tree.obj")
* destJ3o : absoluter Ziel-Pfad der .j3o-Datei
* srcToDelete: absoluter Pfad der Original-Datei (wird nach Konvertierung gelöscht)
* assetPath : Pfad relativ zu blight-assets/src/main/resources/
* destJ3o : absoluter Ziel-Pfad der .j3o-Datei
* srcToDelete : absoluter Pfad der Original-Datei (wird nach Konvertierung gelöscht)
* keepControls : true = AnimComposer/SkinningControl bleiben erhalten (Animationen)
* yRotationDeg : Y-Rotation die beim Import auf das Root-Spatial angewendet wird (Grad)
* centerOrigin : true = Root-Translation auf (0,0,0) setzen
*/
public record ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete) {}
java.nio.file.Path srcToDelete, boolean keepControls,
float yRotationDeg, boolean centerOrigin) {
public ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete) {
this(assetPath, destJ3o, srcToDelete, false, 0f, false);
}
public ModelConvertRequest(String assetPath, java.nio.file.Path destJ3o,
java.nio.file.Path srcToDelete, boolean keepControls) {
this(assetPath, destJ3o, srcToDelete, keepControls, 0f, false);
}
}
public final ConcurrentLinkedQueue<ModelConvertRequest> modelConvertQueue =
new ConcurrentLinkedQueue<>();
}

View File

@@ -0,0 +1,131 @@
package de.blight.editor;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
/** Thin wrapper around the Tripo3D v2 REST API. All methods are blocking. */
public class TripoGenerator {
private static final String BASE = "https://api.tripo3d.ai/v2/openapi";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.build();
// ── Data ─────────────────────────────────────────────────────────────────
public record TaskResult(
String taskId,
String status, // "queued" | "running" | "success" | "failed" | "cancelled"
int progress, // 0100
String modelUrl, // null until success
String previewUrl // null until success
) {}
// ── Public API ────────────────────────────────────────────────────────────
/** Creates a text-to-3D-model task and returns the task ID. */
public static String createTextToModelTask(String apiKey, String prompt) throws IOException {
String body = "{\"type\":\"text_to_model\",\"prompt\":" + jsonString(prompt) + "}";
return postTask(apiKey, body);
}
/**
* Creates a skeleton-rigging task from a previously generated model.
* Tripo adds a humanoid rig suitable for animation retargeting.
*/
public static String createRigTask(String apiKey, String originalTaskId) throws IOException {
String body = "{\"type\":\"animate_rig\",\"original_model_task_id\":" + jsonString(originalTaskId) + "}";
return postTask(apiKey, body);
}
/** Polls task status once (non-blocking polling loop is the caller's responsibility). */
public static TaskResult pollTask(String apiKey, String taskId) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/task/" + taskId))
.header("Authorization", "Bearer " + apiKey)
.GET()
.timeout(Duration.ofSeconds(30))
.build();
JsonObject data = send(req).getAsJsonObject("data");
String status = data.get("status").getAsString();
int progress = data.has("progress") ? data.get("progress").getAsInt() : 0;
String modelUrl = null, previewUrl = null;
if (data.has("output") && !data.get("output").isJsonNull()) {
JsonObject out = data.getAsJsonObject("output");
if (out.has("model")) modelUrl = out.get("model").getAsString();
if (out.has("rendered_image")) previewUrl = out.get("rendered_image").getAsString();
}
return new TaskResult(taskId, status, progress, modelUrl, previewUrl);
}
/** Downloads a URL to the given path (replaces existing file). */
public static void downloadFile(String url, Path dest) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(180))
.build();
try {
HttpResponse<InputStream> resp =
HTTP.send(req, HttpResponse.BodyHandlers.ofInputStream());
if (resp.statusCode() != 200)
throw new IOException("Download HTTP " + resp.statusCode());
Files.createDirectories(dest.getParent());
try (InputStream in = resp.body()) {
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Download unterbrochen", e);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static String postTask(String apiKey, String body) throws IOException {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/task"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.timeout(Duration.ofSeconds(30))
.build();
return send(req).getAsJsonObject("data").get("task_id").getAsString();
}
private static JsonObject send(HttpRequest req) throws IOException {
try {
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject();
int code = json.has("code") ? json.get("code").getAsInt() : -1;
if (code != 0) {
String msg = json.has("message") ? json.get("message").getAsString() : "?";
throw new IOException("Tripo3D-Fehler " + code + ": " + msg);
}
return json;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("HTTP-Anfrage unterbrochen", e);
}
}
private static String jsonString(String s) {
return "\"" + s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
+ "\"";
}
}

View File

@@ -9,9 +9,14 @@ public class SceneObject extends PlacedObject {
private float worldXMut;
private float worldZMut;
private float rotY; // Y-Achsen-Rotation in Radiant
private float rotX; // X-Achsen-Rotation in Radiant
private float rotZ; // Z-Achsen-Rotation in Radiant
private float scale;
public boolean solid; // Charakter-Kollision
public String modelPath; // relativ zu editor-assets/
public String texturePath = "";
public String normalMapPath = "";
public String materialPath = "";
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
boolean solid) {
@@ -19,6 +24,8 @@ public class SceneObject extends PlacedObject {
this.worldXMut = worldX;
this.worldZMut = worldZ;
this.rotY = 0f;
this.rotX = 0f;
this.rotZ = 0f;
this.scale = 1f;
this.solid = solid;
this.modelPath = modelPath;
@@ -30,7 +37,14 @@ public class SceneObject extends PlacedObject {
@Override public float getWorldZ() { return worldZMut; }
public float getRotY() { return rotY; }
public float getRotX() { return rotX; }
public float getRotZ() { return rotZ; }
public float getScale() { return scale; }
public String getTexturePath() { return texturePath; }
public String getNormalMapPath() { return normalMapPath; }
public String getMaterialPath() { return materialPath; }
public void setNormalMapPath(String p) { this.normalMapPath = p != null ? p : ""; }
public void setMaterialPath(String p) { this.materialPath = p != null ? p : ""; }
public void translate(float dx, float dy, float dz) {
worldXMut += dx;
@@ -38,6 +52,20 @@ public class SceneObject extends PlacedObject {
worldZMut += dz;
}
public void setPosition(float x, float y, float z) {
worldXMut = x;
this.groundY = y;
worldZMut = z;
}
public void setRotation(float rx, float ry, float rz) {
rotX = rx;
rotY = ry;
rotZ = rz;
}
public void setTexturePath(String path) { this.texturePath = path != null ? path : ""; }
public void rotateY(float deltaRad) { rotY += deltaRad; }
public void setScale(float s) { scale = s; }
}

View File

@@ -0,0 +1,835 @@
package de.blight.editor.state;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.anim.AnimClip;
import com.jme3.anim.AnimComposer;
import com.jme3.anim.SkinningControl;
import com.jme3.anim.tween.action.Action;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.asset.ModelKey;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.font.BitmapFont;
import com.jme3.font.BitmapText;
import com.jme3.light.AmbientLight;
import com.jme3.light.DirectionalLight;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture2D;
import de.blight.editor.FrameTransfer;
import de.blight.editor.ProjectRoot;
import de.blight.editor.SharedInput;
import javafx.scene.image.WritableImage;
public class AnimPreviewState extends BaseAppState {
private static final Logger LOG = LoggerFactory.getLogger(AnimPreviewState.class);
private static final int PREVIEW_SIZE = 512;
private static final Path ASSET_ROOT =
ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private ViewPort previewVP;
private FrameBuffer previewFB;
private FrameTransfer previewTransfer;
private Node previewScene;
private Node previewHolder;
private Vector3f previewTarget = new Vector3f(0f, 1f, 0f);
private float previewCamDist = 3f;
private int currentW = PREVIEW_SIZE;
private int currentH = PREVIEW_SIZE;
private Spatial currentModel;
private String currentModelPath;
private Action currentAction;
private String currentClipName;
private Node axesNode;
public AnimPreviewState(SharedInput input) { this.input = input; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
previewFB = buildFrameBuffer(PREVIEW_SIZE, PREVIEW_SIZE);
Camera cam = new Camera(PREVIEW_SIZE, PREVIEW_SIZE);
cam.setFrustumPerspective(45f, 1f, 0.01f, 2000f);
previewVP = this.app.getRenderManager().createPostView("animPreview", cam);
previewVP.setOutputFrameBuffer(previewFB);
previewVP.setBackgroundColor(new ColorRGBA(0.18f, 0.18f, 0.22f, 1f));
previewVP.setClearFlags(true, true, true);
previewScene = new Node("animPreviewScene");
previewScene.addLight(new DirectionalLight(
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
previewScene.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.45f, 0.5f, 0.7f, 1f)));
previewScene.addLight(new AmbientLight(new ColorRGBA(0.4f, 0.4f, 0.45f, 1f)));
previewHolder = new Node("animHolder");
previewScene.attachChild(previewHolder);
axesNode = buildAxesNode();
previewScene.attachChild(axesNode);
previewVP.attachScene(previewScene);
previewTransfer = new FrameTransfer(input.animPreviewImage);
previewVP.addProcessor(previewTransfer);
}
@Override
protected void cleanup(Application app) {
if (previewVP != null) {
this.app.getRenderManager().removePostView(previewVP);
previewVP = null;
}
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Update-Schleife ───────────────────────────────────────────────────────
@Override
public void update(float tpf) {
String loadPath = input.animPreviewLoadPath;
if (loadPath != null) {
input.animPreviewLoadPath = null;
loadModel(loadPath);
}
String playClip = input.animPreviewPlayClip;
if (playClip != null) {
input.animPreviewPlayClip = null;
if (playClip.isEmpty()) stopAll();
else playClip(playClip);
}
String addAnimPath = input.animPreviewAddAnimPath;
if (addAnimPath != null) {
input.animPreviewAddAnimPath = null;
addAnimation(addAnimPath);
}
String removeClip = input.animPreviewRemoveClip;
if (removeClip != null) {
input.animPreviewRemoveClip = null;
removeAnimation(removeClip);
}
if (input.scanSkeletalRequested) {
input.scanSkeletalRequested = false;
new Thread(this::scanSkeletalModels, "skeletal-scan").start();
}
if (input.animDumpRequested) {
input.animDumpRequested = false;
dumpCurrentModel();
}
SharedInput.ClipRenameRequest renameReq = input.clipRenameRequest.getAndSet(null);
if (renameReq != null) executeClipRename(renameReq);
SharedInput.AnimSetSaveRequest setReq = input.animSetSaveRequest.getAndSet(null);
if (setReq != null) executeAnimSetSave(setReq);
// Geschwindigkeit live anpassen
if (currentAction != null) {
try { currentAction.setSpeed(input.animPreviewSpeed); } catch (Exception ignored) {}
}
// Loop-Steuerung: AnimComposer spielt einmal ab wir starten manuell neu
if (currentClipName != null && currentModel != null && currentAction != null) {
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac != null) {
double length = currentAction.getLength();
double time = ac.getTime();
if (length <= 0) {
System.err.println("[AnimPreview] Loop-Check: length=0, Clip hat keine Dauer!");
} else if (time >= length - 0.02) {
LOG.trace("[AnimPreview] Loop-Check fired: time={} length={}", time, length);
if (input.animPreviewLoop) {
currentAction = ac.setCurrentAction(currentClipName);
if (currentAction != null) currentAction.setSpeed(input.animPreviewSpeed);
} else {
currentAction = null;
currentClipName = null;
setSkinningEnabled(currentModel, false);
}
}
}
}
// Resize
int reqW = Math.max(64, input.animPreviewW);
int reqH = Math.max(64, input.animPreviewH);
if (previewVP != null && (Math.abs(reqW - currentW) > 8 || Math.abs(reqH - currentH) > 8)) {
resizePreview(reqW, reqH);
}
// Kamera-Orbit
if (previewVP != null) {
float rotY = input.animPreviewRotY * FastMath.DEG_TO_RAD;
float rotX = input.animPreviewRotX * FastMath.DEG_TO_RAD;
float dist = previewCamDist * input.animPreviewZoom;
Camera c = previewVP.getCamera();
c.setLocation(new Vector3f(
previewTarget.x + FastMath.sin(rotY) * FastMath.cos(rotX) * dist,
previewTarget.y + FastMath.sin(rotX) * dist,
previewTarget.z + FastMath.cos(rotY) * FastMath.cos(rotX) * dist));
c.lookAt(previewTarget, Vector3f.UNIT_Y);
// Achsen: Größe proportional zur Kameradistanz, immer am Ursprung
if (axesNode != null) {
float s = previewCamDist * input.animPreviewZoom * 0.18f;
axesNode.setLocalScale(s);
axesNode.setLocalTranslation(previewTarget);
}
previewScene.updateLogicalState(tpf);
previewScene.updateGeometricState();
}
}
// ── Model laden ───────────────────────────────────────────────────────────
private void loadModel(String assetPath) {
previewHolder.detachAllChildren();
currentModel = null;
currentModelPath = null;
currentAction = null;
try {
Spatial model = loadFresh(assetPath);
// SkinningControl nur aktiv lassen wenn eine Animation läuft,
// sonst kollabiert das Mesh durch uninitalisierte Skin-Matrizen.
setSkinningEnabled(model, false);
// Im Animations-Editor soll der Charakter immer am Ursprung stehen.
// Eventuelle Translation die beim GLB-Export eingebacken wurde entfernen.
model.setLocalTranslation(Vector3f.ZERO);
currentModel = model;
currentModelPath = assetPath;
previewHolder.attachChild(model);
// Kamera auf Bounding Box ausrichten
model.updateGeometricState();
if (model.getWorldBound() instanceof BoundingBox bb) {
float ext = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
previewCamDist = ext * 2.8f;
previewTarget.set(bb.getCenter());
} else {
previewCamDist = 3f;
previewTarget.set(0, 1, 0);
}
input.animPreviewZoom = 1.0f;
// Clips sammeln und melden
List<String> clips = new ArrayList<>();
collectClips(model, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewLoadedPath.set(assetPath);
if (clips.isEmpty()) {
if (!hasSkeleton(model)) {
input.animPreviewStatus =
"Kein Skelett gefunden. Modell wurde möglicherweise ohne " +
"\"keepControls\" importiert bitte Datei neu importieren.";
} else {
input.animPreviewStatus = "Skelett vorhanden, aber keine Animations-Clips. " +
"Animation über 'Animation hinzufügen' laden.";
}
} else {
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
}
} catch (Exception e) {
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
input.animPreviewClips.set(List.of());
}
}
private void collectClips(Spatial s, List<String> out) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null) {
List<String> names = new ArrayList<>(ac.getAnimClipsNames());
Collections.sort(names);
out.addAll(names);
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) collectClips(child, out);
}
}
// ── Animation abspielen ───────────────────────────────────────────────────
private void playClip(String clipName) {
if (currentModel == null) return;
currentAction = null;
currentClipName = null;
// SkinningControls auf dem gesamten Modell aktivieren AnimComposer und
// SkinningControl sitzen oft auf verschiedenen Geschwisterknoten.
setSkinningEnabled(currentModel, true);
playOnSpatial(currentModel, clipName);
}
private boolean playOnSpatial(Spatial s, String clipName) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null && ac.getAnimClipsNames().contains(clipName)) {
try {
currentAction = ac.setCurrentAction(clipName);
currentClipName = clipName;
if (currentAction != null) {
currentAction.setSpeed(input.animPreviewSpeed);
System.err.println("[AnimPreview] Play '" + clipName
+ "' length=" + currentAction.getLength());
}
} catch (Exception e) {
input.animPreviewStatus = "Abspielen fehlgeschlagen: " + e.getMessage();
}
return true;
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
if (playOnSpatial(child, clipName)) return true;
}
}
return false;
}
private void stopAll() {
currentAction = null;
currentClipName = null;
if (currentModel != null) {
stopOnSpatial(currentModel);
setSkinningEnabled(currentModel, false);
}
}
private void stopOnSpatial(Spatial s) {
AnimComposer ac = s.getControl(AnimComposer.class);
if (ac != null) {
try { ac.removeCurrentAction(AnimComposer.DEFAULT_LAYER); } catch (Exception ignored) {}
}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) stopOnSpatial(child);
}
}
// ── Skelett-Scan ─────────────────────────────────────────────────────────
private void scanSkeletalModels() {
Set<String> result = new HashSet<>();
for (String dir : new String[]{"Models", "animations"}) {
Path base = ASSET_ROOT.resolve(dir);
if (!Files.isDirectory(base)) continue;
try (Stream<Path> walk = Files.walk(base)) {
walk.filter(p -> p.toString().endsWith(".j3o")).forEach(p -> {
String rel = ASSET_ROOT.relativize(p).toString().replace('\\', '/');
try {
Spatial model = assets.loadModel(rel);
if (hasSkeleton(model)) result.add(rel);
} catch (Exception ignored) {}
});
} catch (IOException ignored) {}
}
input.skeletalPaths.set(Collections.unmodifiableSet(result));
}
private boolean hasSkeleton(Spatial s) {
if (s.getControl(AnimComposer.class) != null) return true;
// Fallback auf altes AnimControl (Legacy-Modelle)
try {
if (s.getControl(com.jme3.animation.AnimControl.class) != null) return true;
} catch (Exception ignored) {}
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
if (hasSkeleton(child)) return true;
}
}
return false;
}
// ── Animation hinzufügen (Retargeting) ───────────────────────────────────
private void addAnimation(String animAssetPath) {
if (currentModel == null) {
input.animPreviewStatus = "Fehler: zuerst ein Modell laden";
return;
}
AnimComposer targetAC = findControl(currentModel, AnimComposer.class);
SkinningControl targetSC = findControl(currentModel, SkinningControl.class);
if (targetAC == null) {
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
return;
}
try {
Spatial animSource = loadFresh(animAssetPath);
AnimComposer sourceAC = findControl(animSource, AnimComposer.class);
if (sourceAC == null) {
String controls = listControlTypes(animSource);
String info = "Kein AnimComposer | Controls: " + controls
+ " | Nodes: " + countNodes(animSource);
input.animPreviewStatus = info;
System.err.println("[AnimPreview] addAnimation " + info + " Datei: " + animAssetPath);
return;
}
if (sourceAC.getAnimClips().isEmpty()) {
input.animPreviewStatus = "AnimComposer leer (0 Clips) in: " + animAssetPath;
System.err.println("[AnimPreview] AnimComposer leer in " + animAssetPath);
return;
}
SkinningControl sourceSC = findControl(animSource, SkinningControl.class);
com.jme3.anim.Armature srcArm = sourceSC != null ? sourceSC.getArmature() : null;
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
// Diagnose: Knochen-Namen beider Skelette ausgeben
if (srcArm != null) {
System.err.println("[Retarget] Quell-Knochen (" + srcArm.getJointCount() + "):");
for (var j : srcArm.getJointList()) System.err.println(" src: " + j.getName());
} else {
System.err.println("[Retarget] Keine SkinningControl in Quelle!");
}
if (dstArm != null) {
System.err.println("[Retarget] Ziel-Knochen (" + dstArm.getJointCount() + "):");
for (var j : dstArm.getJointList()) System.err.println(" dst: " + j.getName());
} else {
System.err.println("[Retarget] Keine SkinningControl im Modell!");
}
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
if (retarget) {
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
System.err.println("[Retarget] Mapping (" + mapping.size() + " Treffer): " + mapping);
}
// Blender-Duplikate herausfiltern: Clips deren Name mit ".NNN" endet und deren
// Basis-Name (ohne Suffix) ebenfalls in der Quelle vorkommt, werden übersprungen.
java.util.Set<String> srcNames = new java.util.HashSet<>();
for (AnimClip c : sourceAC.getAnimClips()) srcNames.add(c.getName());
int added = 0;
for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName();
// Prüfen ob Name dem Muster "base.NNN" entspricht (Blender-Duplikat)
if (name.matches(".*\\.\\d{3}$")) {
String base = name.substring(0, name.length() - 4);
if (srcNames.contains(base)) {
System.err.println("[AnimPreview] Überspringe Blender-Duplikat: " + name);
continue;
}
}
AnimClip result = retarget
? de.blight.game.animation.RetargetingSystem.retarget(clip, srcArm, dstArm)
: clip;
if (result != null) {
targetAC.addAnimClip(result);
added++;
}
}
// Clip-Liste neu aufbauen
List<String> clips = new ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = added + " Clip(s) hinzugefügt"
+ (retarget ? " (retargeted)" : " (direkt, kein Retargeting)");
// Modell mit neuem Clip persistieren, damit der Clip nach Editor-Neustart noch da ist
if (added > 0) saveModel();
} catch (Exception e) {
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
}
}
// ── Animation entfernen ──────────────────────────────────────────────────
private void removeAnimation(String clipName) {
if (currentModel == null) {
input.animPreviewStatus = "Fehler: kein Modell geladen";
return;
}
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) {
input.animPreviewStatus = "Fehler: kein AnimComposer";
return;
}
AnimClip clip = ac.getAnimClip(clipName);
if (clip == null) {
input.animPreviewStatus = "Clip nicht gefunden: " + clipName;
return;
}
if (clipName.equals(currentClipName)) stopAll();
ac.removeAnimClip(clip);
List<String> clips = new ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = "Clip entfernt: " + clipName;
saveModel();
}
private <T extends com.jme3.scene.control.Control> T findControl(Spatial s, Class<T> type) {
T c = s.getControl(type);
if (c != null) return c;
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) {
T r = findControl(child, type);
if (r != null) return r;
}
}
return null;
}
// ── Achsen-Visualisierung ────────────────────────────────────────────────
// ── Achsen: Rot=X Grün=Y Blau=Z ──────────────────────────────────────────
// Solid-Box-Geometrie, depthTest=false → immer vor dem Modell sichtbar.
// Der axesNode sitzt am previewTarget (Modell-Mittelpunkt) und wird per
// setLocalScale auf ~18 % der Kameradistanz skaliert.
private Node buildAxesNode() {
Node n = new Node("axes");
n.attachChild(makeAxisShaft(ColorRGBA.Red, 0.5f, 0f, 0f, "X"));
n.attachChild(makeAxisShaft(ColorRGBA.Green, 0f, 0.5f, 0f, "Y"));
n.attachChild(makeAxisShaft(ColorRGBA.Blue, 0f, 0f, 0.5f, "Z"));
n.attachChild(makeAxisTip(ColorRGBA.Red, 0.5f, 0f, 0f ));
n.attachChild(makeAxisTip(ColorRGBA.Green, 0f, 0.5f, 0f ));
n.attachChild(makeAxisTip(ColorRGBA.Blue, 0f, 0f, 0.5f ));
addAxisLabel(n, new Vector3f(0.60f, 0.00f, 0.00f), "X", ColorRGBA.Red);
addAxisLabel(n, new Vector3f(0.00f, 0.60f, 0.00f), "Y", ColorRGBA.Green);
addAxisLabel(n, new Vector3f(0.00f, 0.00f, 0.60f), "Z", ColorRGBA.Blue);
return n;
}
/** Dünner Stab von (0,0,0) zum Punkt (ex,ey,ez), halb-Breite 0.015. */
private Geometry makeAxisShaft(ColorRGBA color, float ex, float ey, float ez, String name) {
float hw = 0.015f;
float hx = ex != 0 ? Math.abs(ex) / 2f : hw;
float hy = ey != 0 ? Math.abs(ey) / 2f : hw;
float hz = ez != 0 ? Math.abs(ez) / 2f : hw;
Geometry g = new Geometry("axis-" + name, new com.jme3.scene.shape.Box(hx, hy, hz));
// Mittelpunkt des Stabs liegt bei halber Länge
g.setLocalTranslation(ex / 2f, ey / 2f, ez / 2f);
g.setMaterial(unshadedDepthOff(color));
g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent);
return g;
}
/** Kleiner Würfel an der Spitze (tx,ty,tz). */
private Geometry makeAxisTip(ColorRGBA color, float tx, float ty, float tz) {
Geometry g = new Geometry("axis-tip",
new com.jme3.scene.shape.Box(0.04f, 0.04f, 0.04f));
g.setLocalTranslation(tx, ty, tz);
g.setMaterial(unshadedDepthOff(color));
g.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent);
return g;
}
private Material unshadedDepthOff(ColorRGBA color) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setDepthWrite(false);
return mat;
}
private void addAxisLabel(Node parent, Vector3f pos, String text, ColorRGBA color) {
try {
BitmapFont font = assets.loadFont("Interface/Fonts/Default.fnt");
BitmapText label = new BitmapText(font, false);
label.setSize(0.18f);
label.setColor(color);
label.setText(text);
label.setLocalTranslation(pos);
parent.attachChild(label);
} catch (Exception ignored) {}
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
/** Listet alle Control-Typen im Subgraph für Diagnose-Ausgaben. */
private String listControlTypes(Spatial s) {
List<String> types = new ArrayList<>();
collectControlTypes(s, types);
return types.isEmpty() ? "(keine)" : String.join(", ", types);
}
private int countNodes(Spatial s) {
int n = 1;
if (s instanceof Node nd) for (Spatial c : nd.getChildren()) n += countNodes(c);
return n;
}
private void collectControlTypes(Spatial s, List<String> out) {
for (int i = 0; i < s.getNumControls(); i++)
out.add(s.getControl(i).getClass().getSimpleName());
if (s instanceof Node n)
for (Spatial child : n.getChildren()) collectControlTypes(child, out);
}
/** Speichert das aktuelle Modell (inkl. aller AnimClips) zurück auf Disk. */
private void saveModel() {
if (currentModelPath == null || currentModel == null) return;
if (!currentModelPath.endsWith(".j3o")) {
System.err.println("[AnimPreview] Speichern übersprungen kein .j3o: " + currentModelPath);
return;
}
Path file = ASSET_ROOT.resolve(currentModelPath.replace('/', java.io.File.separatorChar));
try {
BinaryExporter.getInstance().save(currentModel, file.toFile());
System.err.println("[AnimPreview] Modell gespeichert: " + currentModelPath);
assets.deleteFromCache(new ModelKey(currentModelPath));
} catch (Exception e) {
input.animPreviewStatus += " | Speicherfehler: " + e.getMessage();
System.err.println("[AnimPreview] Speicherfehler: " + e);
}
}
/** Lädt eine j3o-Datei direkt von Disk (BinaryImporter), ohne AssetManager-Cache. */
private Spatial loadFresh(String assetPath) throws Exception {
Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar));
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
BinaryImporter bi = BinaryImporter.getInstance();
bi.setAssetManager(assets);
return (Spatial) bi.load(file.toFile());
}
assets.deleteFromCache(new ModelKey(assetPath));
return assets.loadModel(assetPath);
}
/** Aktiviert oder deaktiviert alle SkinningControls im Subgraph. */
private void setSkinningEnabled(Spatial s, boolean enabled) {
SkinningControl sc = s.getControl(SkinningControl.class);
if (sc != null) sc.setEnabled(enabled);
if (s instanceof Node n) {
for (Spatial child : n.getChildren()) setSkinningEnabled(child, enabled);
}
}
// ── Framebuffer ───────────────────────────────────────────────────────────
private FrameBuffer buildFrameBuffer(int w, int h) {
FrameBuffer fb = new FrameBuffer(w, h, 1);
fb.addColorTexture(new Texture2D(w, h, Image.Format.RGBA8));
fb.setDepthTexture(new Texture2D(w, h, Image.Format.Depth));
return fb;
}
private void resizePreview(int newW, int newH) {
currentW = newW;
currentH = newH;
previewVP.removeProcessor(previewTransfer);
try { previewFB.dispose(); } catch (Exception ignored) {}
previewFB = buildFrameBuffer(newW, newH);
previewVP.setOutputFrameBuffer(previewFB);
Camera cam = previewVP.getCamera();
cam.resize(newW, newH, true);
cam.setFrustumPerspective(45f, (float) newW / newH, 0.01f, 2000f);
WritableImage newImg = new WritableImage(newW, newH);
previewTransfer = new FrameTransfer(newImg);
previewVP.addProcessor(previewTransfer);
input.animPreviewImage = newImg;
input.animPreviewResized = true;
}
// ── Skelett-Dump ──────────────────────────────────────────────────────────
private void dumpCurrentModel() {
if (currentModel == null) {
System.err.println("[Dump] Kein Modell geladen.");
return;
}
System.err.println("=".repeat(70));
System.err.println("[Dump] Modell: " + currentModelPath);
com.jme3.anim.SkinningControl sc = findControl(currentModel, com.jme3.anim.SkinningControl.class);
com.jme3.anim.AnimComposer ac = findControl(currentModel, com.jme3.anim.AnimComposer.class);
if (sc == null) {
System.err.println("[Dump] Kein SkinningControl gefunden kein Skelett.");
} else {
com.jme3.anim.Armature arm = sc.getArmature();
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> ms = buildMS(arm);
System.err.println("[Dump] Skelett: " + arm.getJointCount() + " Knochen");
System.err.println("[Dump] ── Knochen (Name | Elternteil | bind-local° | live-local° | ms°) ──");
for (com.jme3.anim.Joint j : arm.getJointList()) {
String parent = j.getParent() != null ? j.getParent().getName() : "(root)";
com.jme3.math.Transform bind = j.getInitialTransform();
com.jme3.math.Quaternion bindRot = bind != null ? bind.getRotation() : new com.jme3.math.Quaternion();
// live = was AnimComposer aktuell in den Knochen geschrieben hat
com.jme3.math.Transform live = j.getLocalTransform();
com.jme3.math.Quaternion liveRot = live != null ? live.getRotation() : new com.jme3.math.Quaternion();
float[] be = bindRot.toAngles(null);
float[] le = liveRot.toAngles(null);
float[] mse = ms.get(j).toAngles(null);
System.err.printf("[Dump] %-30s parent=%-25s bind=[%6.1f %6.1f %6.1f]° live=[%6.1f %6.1f %6.1f]° ms=[%6.1f %6.1f %6.1f]°%n",
j.getName(), parent,
Math.toDegrees(be[0]), Math.toDegrees(be[1]), Math.toDegrees(be[2]),
Math.toDegrees(le[0]), Math.toDegrees(le[1]), Math.toDegrees(le[2]),
Math.toDegrees(mse[0]), Math.toDegrees(mse[1]), Math.toDegrees(mse[2]));
}
// Bone-Name-Mapping gegen Standard-Namen
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(arm, arm);
System.err.println("[Dump] ── Bone-Name-Normalisierung ──");
for (com.jme3.anim.Joint j : arm.getJointList()) {
String norm = de.blight.game.animation.BoneNameMapping.normalize(j.getName());
System.err.printf("[Dump] %-30s → normalized: %s%n", j.getName(), norm);
}
}
if (ac == null) {
System.err.println("[Dump] Kein AnimComposer gefunden keine Clips.");
} else {
System.err.println("[Dump] ── Clips ──");
for (com.jme3.anim.AnimClip clip : ac.getAnimClips()) {
System.err.printf("[Dump] Clip: %-40s Dauer=%.3fs Tracks=%d%n",
clip.getName(), clip.getLength(), clip.getTracks().length);
for (com.jme3.anim.AnimTrack<?> track : clip.getTracks()) {
if (track instanceof com.jme3.anim.TransformTrack tt
&& tt.getTarget() instanceof com.jme3.anim.Joint j) {
System.err.printf("[Dump] Track: %-28s frames=%d%n",
j.getName(),
tt.getTimes() != null ? tt.getTimes().length : 0);
}
}
}
}
System.err.println("=".repeat(70));
input.animPreviewStatus = "Dump ins Log geschrieben (stderr)";
}
// ── Clip umbenennen / exportieren ─────────────────────────────────────────
private void executeClipRename(SharedInput.ClipRenameRequest req) {
if (currentModel == null) { input.animOpStatus = "Fehler: kein Modell geladen"; return; }
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) { input.animOpStatus = "Fehler: kein AnimComposer"; return; }
AnimClip src = ac.getAnimClip(req.oldName());
if (src == null) { input.animOpStatus = "Clip nicht gefunden: " + req.oldName(); return; }
// Neuen AnimClip mit neuem Namen und denselben Tracks erstellen
AnimClip renamed = new AnimClip(req.newName());
renamed.setTracks(src.getTracks());
ac.addAnimClip(renamed);
saveModel();
// Als eigenständige .j3o nach animations/ exportieren
try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animExport");
AnimComposer expAC = new AnimComposer();
expAC.addAnimClip(renamed);
holder.addControl(expAC);
java.nio.file.Path outDir = ASSET_ROOT.resolve("animations");
java.nio.file.Files.createDirectories(outDir);
com.jme3.export.binary.BinaryExporter.getInstance()
.save(holder, outDir.resolve(req.newName() + ".j3o").toFile());
// Clip-Liste aktualisieren
java.util.List<String> clips = new java.util.ArrayList<>();
collectClips(currentModel, clips);
input.animPreviewClips.set(java.util.Collections.unmodifiableList(clips));
input.animOpStatus = "Clip als '" + req.newName() + "' gespeichert";
} catch (Exception e) {
input.animOpStatus = "Export-Fehler: " + e.getMessage();
}
}
// ── Animations-Set speichern ──────────────────────────────────────────────
private void executeAnimSetSave(SharedInput.AnimSetSaveRequest req) {
if (currentModel == null) { input.animOpStatus = "Fehler: kein Modell geladen"; return; }
AnimComposer ac = findControl(currentModel, AnimComposer.class);
if (ac == null) { input.animOpStatus = "Fehler: kein AnimComposer"; return; }
try {
com.jme3.scene.Node holder = new com.jme3.scene.Node("animSet");
AnimComposer setAC = new AnimComposer();
int added = 0;
for (String clipName : req.clips()) {
AnimClip clip = ac.getAnimClip(clipName);
if (clip != null) { setAC.addAnimClip(clip); added++; }
}
holder.addControl(setAC);
java.nio.file.Path setDir = ASSET_ROOT.resolve("animations").resolve("sets");
java.nio.file.Files.createDirectories(setDir);
java.nio.file.Path j3oPath = setDir.resolve(req.setName() + ".j3o");
com.jme3.export.binary.BinaryExporter.getInstance().save(holder, j3oPath.toFile());
// Begleitende .animset.json mit Clip-Namen und Aktions-Zuweisung
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(req.clips());
animSet.setActionMap(req.actionMap() != null ? req.actionMap() : new java.util.LinkedHashMap<>());
animSet.save(setDir, req.setName());
input.animOpStatus = "Set '" + req.setName() + "' gespeichert (" + added + " Clips)";
} catch (Exception e) {
input.animOpStatus = "Set-Fehler: " + e.getMessage();
}
}
private static java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion>
buildMS(com.jme3.anim.Armature arm) {
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache = new java.util.HashMap<>();
for (com.jme3.anim.Joint j : arm.getJointList()) buildMSRec(j, cache);
return cache;
}
private static com.jme3.math.Quaternion buildMSRec(
com.jme3.anim.Joint j,
java.util.Map<com.jme3.anim.Joint, com.jme3.math.Quaternion> cache) {
com.jme3.math.Quaternion cached = cache.get(j);
if (cached != null) return cached;
com.jme3.math.Quaternion local = j.getInitialTransform() != null
? j.getInitialTransform().getRotation() : new com.jme3.math.Quaternion();
com.jme3.math.Quaternion result = j.getParent() == null
? new com.jme3.math.Quaternion(local)
: buildMSRec(j.getParent(), cache).mult(local);
cache.put(j, result);
return result;
}
}

View File

@@ -0,0 +1,326 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.effect.ParticleEmitter;
import com.jme3.effect.ParticleMesh;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.PlacedEmitter;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
public class EmitterState extends BaseAppState {
private static final float MARKER_RADIUS = 0.4f;
private static final float ACTIVATION_ALPHA = 0.07f;
private static final ColorRGBA SEL_COLOR = new ColorRGBA(1f, 1f, 0f, 1f);
private static final String GEO_MARKER = "emitter_marker";
private static final String GEO_ACTRADIUS = "activation_sphere";
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists: emitters[i] <-> markers[i] <-> particles[i]
private final List<PlacedEmitter> emitters = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private final List<ParticleEmitter> particles = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedEmitter> pendingEmitters = null;
public EmitterState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingEmitters != null) {
loadPlacedEmitters(pendingEmitters);
pendingEmitters = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_EMITTERS) return;
SharedInput.EmitterClick click;
while ((click = input.emitterClickQueue.poll()) != null) {
handleClick(click);
}
PlacedEmitter pending = input.pendingEmitter.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.deleteEmitterRequested) {
input.deleteEmitterRequested = false;
if (selectedIdx >= 0) removeEmitter(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.EmitterClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
int hit = pickMarker(ray);
if (hit >= 0) {
if (click.rightButton()) deselect();
else selectEmitter(hit);
return;
}
if (click.rightButton()) { deselect(); return; }
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
PlacedEmitter pe = createPreset(input.emitterPreset, pt.x, pt.y, pt.z);
addEmitter(pe);
selectEmitter(emitters.size() - 1);
}
private static PlacedEmitter createPreset(int preset, float x, float y, float z) {
return switch (preset) {
case 1 -> PlacedEmitter.smoke(x, y, z);
case 2 -> PlacedEmitter.sparks(x, y, z);
default -> PlacedEmitter.fire(x, y, z);
};
}
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectEmitter(int idx) {
deselect();
selectedIdx = idx;
setMarkerColor(idx, SEL_COLOR);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < emitters.size()) {
PlacedEmitter e = emitters.get(selectedIdx);
setMarkerColor(selectedIdx, new ColorRGBA(e.startR(), e.startG(), e.startB(), 1f));
}
selectedIdx = -1;
input.selectedEmitterInfo = null;
input.emitterSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedEmitter e = emitters.get(idx);
input.selectedEmitterInfo = buildInfoString(idx, e);
input.emitterSelectionChanged = true;
}
static String buildInfoString(int idx, PlacedEmitter e) {
return String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%s|%d|%d"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%.4f"
+ "|%.4f|%.4f|%d|%.4f",
idx, e.x(), e.y(), e.z(), e.activationRadius(),
e.texturePath(), e.imagesX(), e.imagesY(),
e.startR(), e.startG(), e.startB(), e.startA(),
e.endR(), e.endG(), e.endB(), e.endA(),
e.startSize(), e.endSize(),
e.velX(), e.velY(), e.velZ(), e.velocityVariation(),
e.gravX(), e.gravY(), e.gravZ(),
e.lowLife(), e.highLife(), e.maxParticles(), e.emitRate());
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addEmitter(PlacedEmitter pe) {
Node marker = buildMarker(pe);
rootNode.attachChild(marker);
markers.add(marker);
ParticleEmitter particle = buildParticleEmitter(pe);
if (particle != null) rootNode.attachChild(particle);
particles.add(particle);
emitters.add(pe);
}
private void removeEmitter(int idx) {
rootNode.detachChild(markers.get(idx));
ParticleEmitter pe = particles.get(idx);
if (pe != null) rootNode.detachChild(pe);
emitters.remove(idx);
markers.remove(idx);
particles.remove(idx);
selectedIdx = -1;
input.selectedEmitterInfo = null;
input.emitterSelectionChanged = true;
}
private void clearAll() {
for (Node m : markers) rootNode.detachChild(m);
for (ParticleEmitter pe : particles) if (pe != null) rootNode.detachChild(pe);
emitters.clear();
markers.clear();
particles.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, PlacedEmitter updated) {
rootNode.detachChild(markers.get(idx));
ParticleEmitter oldPe = particles.get(idx);
if (oldPe != null) rootNode.detachChild(oldPe);
Node newMarker = buildMarker(updated);
setMarkerColor(newMarker, SEL_COLOR);
rootNode.attachChild(newMarker);
markers.set(idx, newMarker);
ParticleEmitter newPe = buildParticleEmitter(updated);
if (newPe != null) rootNode.attachChild(newPe);
particles.set(idx, newPe);
emitters.set(idx, updated);
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
private Node buildMarker(PlacedEmitter pe) {
ColorRGBA color = new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), 1f);
Material sphereMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
sphereMat.setColor("Color", color);
Geometry sphere = new Geometry(GEO_MARKER, new Sphere(10, 10, MARKER_RADIUS));
sphere.setMaterial(sphereMat);
Material activMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
activMat.setColor("Color", new ColorRGBA(color.r, color.g, color.b, ACTIVATION_ALPHA));
activMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
activMat.getAdditionalRenderState().setDepthWrite(false);
activMat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Front);
Geometry activSphere = new Geometry(GEO_ACTRADIUS, new Sphere(8, 8, pe.activationRadius()));
activSphere.setMaterial(activMat);
activSphere.setQueueBucket(RenderQueue.Bucket.Transparent);
Node node = new Node("emitter_node");
node.attachChild(activSphere);
node.attachChild(sphere);
node.setLocalTranslation(pe.x(), pe.y(), pe.z());
return node;
}
private ParticleEmitter buildParticleEmitter(PlacedEmitter pe) {
try {
ParticleEmitter effect = new ParticleEmitter(
"particle_effect", ParticleMesh.Type.Triangle, pe.maxParticles());
Material mat = new Material(assets, "Common/MatDefs/Misc/Particle.j3md");
mat.setTexture("Texture", assets.loadTexture(pe.texturePath()));
effect.setMaterial(mat);
effect.setImagesX(pe.imagesX());
effect.setImagesY(pe.imagesY());
effect.setStartColor(new ColorRGBA(pe.startR(), pe.startG(), pe.startB(), pe.startA()));
effect.setEndColor( new ColorRGBA(pe.endR(), pe.endG(), pe.endB(), pe.endA()));
effect.setStartSize(pe.startSize());
effect.setEndSize(pe.endSize());
effect.getParticleInfluencer()
.setInitialVelocity(new Vector3f(pe.velX(), pe.velY(), pe.velZ()));
effect.getParticleInfluencer().setVelocityVariation(pe.velocityVariation());
effect.setGravity(pe.gravX(), pe.gravY(), pe.gravZ());
effect.setLowLife(pe.lowLife());
effect.setHighLife(pe.highLife());
effect.setParticlesPerSec(pe.emitRate());
effect.setLocalTranslation(pe.x(), pe.y(), pe.z());
return effect;
} catch (Exception e) {
System.err.println("[EmitterState] Textur nicht ladbar: " + pe.texturePath()
+ "" + e.getMessage());
return null;
}
}
private void setMarkerColor(int idx, ColorRGBA color) {
setMarkerColor(markers.get(idx), color);
}
private static void setMarkerColor(Node node, ColorRGBA color) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && GEO_MARKER.equals(geo.getName())) {
geo.getMaterial().setColor("Color", color);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedEmitter> getPlacedEmitters() {
return new ArrayList<>(emitters);
}
public void loadPlacedEmitters(List<PlacedEmitter> loaded) {
if (rootNode == null) {
pendingEmitters = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedEmitter pe : loaded) addEmitter(pe);
}
}

View File

@@ -1,5 +1,7 @@
package de.blight.editor.state;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
@@ -19,8 +21,10 @@ 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.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
@@ -29,37 +33,45 @@ import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
import de.blight.eztree.Tree;
import de.blight.eztree.TreeOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
/**
* JME3-AppState für den EZ-Tree-Generator.
*
* Teilt den Vorschau-Viewport mit {@link TreeGeneratorState} (kein eigenes Framebuffer).
* Verarbeitet {@link SharedInput.EzTreeGenRequest}-Einträge aus der Queue,
* baut einen {@link Tree}-Node, weist Materialien zu und zeigt ihn in der Vorschau.
* Optional: .j3o-Export mit Impostor-PNG.
* Versucht zuerst, Geometrie über das npm-Paket @dgreenheck/ez-tree via Node.js
* zu generieren (höhere Qualität). Fällt auf den Java-Port zurück wenn Node.js
* nicht verfügbar ist oder fehlschlägt.
*/
public class EzTreeState extends BaseAppState {
private static final int IMPOSTOR_SIZE = 512;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private static final Logger log = LoggerFactory.getLogger(EzTreeState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private static final Path BLIGHT_ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("blight-assets", "src", "main", "resources");
private static final Path TOOLS_DIR = de.blight.editor.ProjectRoot.resolve("tools");
private static final Gson GSON = new Gson();
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private TreeGeneratorState previewHost;
// ── Laufende Capture-Operation ────────────────────────────────────────────
// ── Capture-Phase ────────────────────────────────────────────────────────
private SharedInput.EzTreeGenRequest pendingRequest = null;
private Node pendingTreeNode = null;
private ViewPort captureVP = null;
@@ -74,7 +86,6 @@ public class EzTreeState extends BaseAppState {
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
// previewHost via lazy-init in update() TreeGeneratorState evtl. noch nicht attached
}
@Override protected void cleanup(Application app) { cleanupCapture(); }
@@ -85,7 +96,6 @@ public class EzTreeState extends BaseAppState {
@Override
public void update(float tpf) {
// Lazy-init: TreeGeneratorState muss initialisiert sein, bevor wir darauf zugreifen
if (previewHost == null) {
previewHost = getStateManager().getState(TreeGeneratorState.class);
if (previewHost == null) return;
@@ -104,12 +114,14 @@ public class EzTreeState extends BaseAppState {
private void startGeneration(SharedInput.EzTreeGenRequest req) {
cleanupCapture();
Tree tree = new Tree(req.options());
tree.generate();
applyMaterials(tree, req.options());
tree.updateGeometricState();
Node treeNode = tryNodeJsGeneration(req);
if (treeNode == null) {
treeNode = javaFallback(req);
}
final Node finalNode = treeNode;
finalNode.updateGeometricState();
BoundingBox bb = boundsOf(tree);
BoundingBox bb = boundsOf(finalNode);
float camDist = bb != null
? Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3.2f
: 20f;
@@ -117,14 +129,12 @@ public class EzTreeState extends BaseAppState {
? new Vector3f(0f, bb.getCenter().y, 0f)
: new Vector3f(0f, 5f, 0f);
// Szenenänderung über enqueue() läuft am Anfang des nächsten Frames,
// bevor TreeGeneratorState.update() updateGeometricState() aufruft.
final float dist = camDist;
final Vector3f tgt = target;
app.enqueue(() -> {
previewHost.setPreviewContent(tree, dist, tgt);
previewHost.setPreviewContent(finalNode, dist, tgt);
if (req.exportAfter()) {
setupCapture(tree, boundsOf(tree), req);
setupCapture(finalNode, boundsOf(finalNode), req);
}
});
@@ -135,9 +145,196 @@ public class EzTreeState extends BaseAppState {
}
}
// ── Node.js-Generierung ───────────────────────────────────────────────────
private Node tryNodeJsGeneration(SharedInput.EzTreeGenRequest req) {
String presetName = req.presetName();
if (presetName == null || presetName.isBlank()) return null;
Path polyfill = TOOLS_DIR.resolve("dom_polyfill.cjs");
Path script = TOOLS_DIR.resolve("ez_tree_generate.mjs");
if (!Files.exists(polyfill) || !Files.exists(script)) return null;
String nodeInput = GSON.toJson(new NodeInput(presetName, buildJsParams(req.options())));
try {
ProcessBuilder pb = new ProcessBuilder(
"node",
"--require", polyfill.toAbsolutePath().toString(),
script.toAbsolutePath().toString(),
nodeInput
);
pb.directory(TOOLS_DIR.toFile());
pb.redirectErrorStream(false);
Process proc = pb.start();
// read stdout and stderr concurrently to avoid pipe-buffer deadlock
StringBuilder out = new StringBuilder();
StringBuilder err = new StringBuilder();
Thread outT = new Thread(() -> {
try (InputStream is = proc.getInputStream()) {
out.append(new String(is.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException ignored) {}
});
Thread errT = new Thread(() -> {
try (InputStream is = proc.getErrorStream()) {
err.append(new String(is.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException ignored) {}
});
outT.start(); errT.start();
boolean ok = proc.waitFor(30, TimeUnit.SECONDS);
outT.join(5000); errT.join(1000);
if (!ok || proc.exitValue() != 0) {
log.warn("[EzTree] Node.js Fehler (exit {}): {}", proc.exitValue(), err.toString().trim());
return null;
}
if (!err.isEmpty()) log.debug("[EzTree] Node.js stderr: {}", err.toString().trim());
return buildNodeFromJson(out.toString(), req);
} catch (Exception e) {
log.warn("[EzTree] Node.js nicht verfügbar: {}", e.getMessage());
return null;
}
}
private record NodeInput(String preset, java.util.Map<String, Object> params) {}
private static java.util.Map<String, Object> buildJsParams(TreeOptions opts) {
var p = new java.util.LinkedHashMap<String, Object>();
p.put("seed", opts.seed);
p.put("type", opts.type == de.blight.eztree.TreeType.EVERGREEN ? "evergreen" : "deciduous");
// bark
var bark = new java.util.LinkedHashMap<String, Object>();
bark.put("tint", rgbToTint(opts.bark.r, opts.bark.g, opts.bark.b));
bark.put("flatShading", opts.bark.flatShading);
bark.put("textureScale", java.util.Map.of("x", opts.bark.textureScaleX, "y", opts.bark.textureScaleY));
p.put("bark", bark);
// branch — only send user-controlled value; all internal preset values (angle, children,
// gnarliness, length, radius, sections, segments, start, taper, twist) are left to the
// JS preset file, because Java has adapted them for its own renderer (capped pine children,
// JME-scaled length/radius, degrees instead of radians for twist, etc.)
p.put("branch", java.util.Map.of("levels", opts.branch.levels));
// leaves
var leaves = new java.util.LinkedHashMap<String, Object>();
leaves.put("tint", rgbToTint(opts.leaves.r, opts.leaves.g, opts.leaves.b));
leaves.put("billboard", opts.leaves.billboard == de.blight.eztree.Billboard.CROSS ? "double" : "single");
leaves.put("count", opts.leaves.count);
leaves.put("start", opts.leaves.start);
leaves.put("size", opts.leaves.size * 5f); // Java stores size ÷ 5 vs JS absolute
leaves.put("sizeVariance", opts.leaves.sizeVariance);
leaves.put("alphaTest", opts.leaves.alphaTest);
leaves.put("angle", opts.leaves.angle);
p.put("leaves", leaves);
// trellis
p.put("trellis", java.util.Map.of("enabled", opts.trellis.enabled));
return p;
}
private static int rgbToTint(float r, float g, float b) {
return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255);
}
private Node buildNodeFromJson(String json, SharedInput.EzTreeGenRequest req) {
try {
JsonObject root = GSON.fromJson(json, JsonObject.class);
Mesh branchMesh = parseMesh(root.getAsJsonObject("branches"), true);
Mesh leafMesh = parseMesh(root.getAsJsonObject("leaves"), true);
Geometry barkGeo = new Geometry("bark", branchMesh);
Geometry leavesGeo = new Geometry("leaves", leafMesh);
barkGeo.setMaterial(buildBarkMat(req.options()));
barkGeo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
leavesGeo.setMaterial(buildLeafMat(req.options()));
leavesGeo.setQueueBucket(RenderQueue.Bucket.Transparent);
leavesGeo.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
Node node = new Node("EzTree");
node.attachChild(barkGeo);
node.attachChild(leavesGeo);
return node;
} catch (Exception e) {
log.warn("[EzTree] JSON→Mesh Fehler: {}", e.getMessage());
return null;
}
}
private static Mesh parseMesh(JsonObject geo, boolean hasUvs) {
float[] positions = toFloatArray(geo.getAsJsonArray("positions"));
float[] normals = toFloatArray(geo.getAsJsonArray("normals"));
int[] indices = toIntArray(geo.getAsJsonArray("indices"));
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3,
BufferUtils.createFloatBuffer(positions));
if (normals.length > 0) {
mesh.setBuffer(VertexBuffer.Type.Normal, 3,
BufferUtils.createFloatBuffer(normals));
}
if (hasUvs && geo.has("uvs")) {
float[] uvs = toFloatArray(geo.getAsJsonArray("uvs"));
if (uvs.length > 0) {
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2,
BufferUtils.createFloatBuffer(uvs));
}
}
// Use short buffer if fits, otherwise int buffer
if (indices.length > 0) {
if (canFitShort(indices)) {
short[] shorts = new short[indices.length];
for (int i = 0; i < indices.length; i++) shorts[i] = (short) indices[i];
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createShortBuffer(shorts));
} else {
mesh.setBuffer(VertexBuffer.Type.Index, 3,
BufferUtils.createIntBuffer(indices));
}
}
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
private static float[] toFloatArray(com.google.gson.JsonArray arr) {
float[] out = new float[arr.size()];
for (int i = 0; i < out.length; i++) out[i] = arr.get(i).getAsFloat();
return out;
}
private static int[] toIntArray(com.google.gson.JsonArray arr) {
int[] out = new int[arr.size()];
for (int i = 0; i < out.length; i++) out[i] = arr.get(i).getAsInt();
return out;
}
private static boolean canFitShort(int[] indices) {
for (int idx : indices) if (idx > 65535) return false;
return true;
}
// ── Java-Fallback ─────────────────────────────────────────────────────────
private Node javaFallback(SharedInput.EzTreeGenRequest req) {
Tree tree = new Tree(req.options());
tree.generate();
applyMaterials(tree, req.options());
return tree;
}
// ── Phase 2: Impostor-Capture ─────────────────────────────────────────────
private void setupCapture(Tree tree, BoundingBox bb, SharedInput.EzTreeGenRequest req) {
private void setupCapture(Node treeNode, BoundingBox bb, SharedInput.EzTreeGenRequest req) {
BoundingBox safeBb = bb != null ? bb : new BoundingBox(Vector3f.ZERO, 5f, 10f, 5f);
Texture2D capTex = new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.RGBA8);
@@ -145,22 +342,25 @@ public class EzTreeState extends BaseAppState {
captureFB.addColorTexture(capTex);
captureFB.setDepthTexture(new Texture2D(IMPOSTOR_SIZE, IMPOSTOR_SIZE, Image.Format.Depth));
captureVP = buildCaptureViewPort(tree, safeBb, captureFB);
captureVP = buildCaptureViewPort(treeNode, safeBb, captureFB);
captureReady = false;
pendingRequest = req;
pendingTreeNode = tree;
pendingTreeNode = treeNode;
input.treeGenStatusMsg = "EZ-Tree: rendere Impostor…";
}
private void finishCapture() {
ByteBuffer pixels = BufferUtils.createByteBuffer(IMPOSTOR_SIZE * IMPOSTOR_SIZE * 4);
app.getRenderer().readFrameBuffer(captureFB, pixels);
SharedInput.EzTreeGenRequest req = pendingRequest;
Node treeNode = pendingTreeNode;
cleanupCapture();
String exportName = pendingRequest.exportName() + "_"
String exportName = req.exportName() + "_"
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
saveImpostor(pixels, "ez_impostor_" + exportName);
exportTree(pendingTreeNode, exportName);
exportTree(treeNode, req.exportName(), req.treeCategory());
pendingRequest = null;
pendingTreeNode = null;
@@ -183,7 +383,6 @@ public class EzTreeState extends BaseAppState {
}
}
} else if (child instanceof Node trellis) {
// Trellis-Node: Rinden-Material auf alle Geometrien
Material mat = buildBarkMat(opts);
for (Spatial s : trellis.getChildren()) {
if (s instanceof Geometry g) g.setMaterial(mat.clone());
@@ -238,7 +437,7 @@ public class EzTreeState extends BaseAppState {
// ── Offscreen-Viewport für Impostor ───────────────────────────────────────
private ViewPort buildCaptureViewPort(Tree src, BoundingBox bb, FrameBuffer fb) {
private ViewPort buildCaptureViewPort(Node src, BoundingBox bb, FrameBuffer fb) {
Vector3f center = bb.getCenter().add(0f, 2f, 0f);
float extent = Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent()));
float dist = extent * 3f;
@@ -279,7 +478,7 @@ public class EzTreeState extends BaseAppState {
return vp;
}
private static Node cloneForCapture(Tree src) {
private static Node cloneForCapture(Node src) {
Node copy = new Node("ezCap");
copy.setLocalTranslation(src.getLocalTranslation());
for (Spatial child : src.getChildren()) {
@@ -304,8 +503,9 @@ public class EzTreeState extends BaseAppState {
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static BoundingBox boundsOf(Tree tree) {
if (tree.getWorldBound() instanceof BoundingBox bb) return bb;
private static BoundingBox boundsOf(Node node) {
node.updateModelBound();
if (node.getWorldBound() instanceof BoundingBox bb) return bb;
return null;
}
@@ -337,19 +537,24 @@ public class EzTreeState extends BaseAppState {
Files.createDirectories(texDir);
ImageIO.write(img, "PNG", texDir.resolve(name + ".png").toFile());
} catch (IOException e) {
System.err.println("[EzTreeState] Impostor-Fehler: " + e.getMessage());
log.error("[EzTree] Impostor-Fehler: {}", e.getMessage());
}
}
private void exportTree(Node treeNode, String name) {
private void exportTree(Node treeNode, String name, String treeCategory) {
try {
Path modelDir = ASSET_ROOT.resolve("models");
Files.createDirectories(modelDir);
File out = modelDir.resolve("EzTree_" + name + ".j3o").toFile();
Path baseDir = (treeCategory != null && !treeCategory.isBlank())
? BLIGHT_ASSET_ROOT.resolve("trees").resolve(treeCategory)
: ASSET_ROOT.resolve("models");
Files.createDirectories(baseDir);
File out = baseDir.resolve(name + ".j3o").toFile();
BinaryExporter.getInstance().save(treeNode, out);
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
input.refreshAssets = true;
log.info("[EZ-Tree] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "EZ-Tree exportiert: " + out.getName();
input.refreshAssets = true;
input.refreshTreeFolders = true;
} catch (IOException e) {
log.error("[EZ-Tree] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "EZ-Tree Export-Fehler: " + e.getMessage();
}
}

View File

@@ -0,0 +1,320 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.light.PointLight;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Cylinder;
import com.jme3.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.PlacedLight;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
public class LightState extends BaseAppState {
// ── Glühbirnen-Geometrie ──────────────────────────────────────────────────
private static final float BULB_RADIUS = 0.38f;
private static final float SOCKET_RADIUS = 0.14f;
private static final float SOCKET_HEIGHT = 0.28f;
private static final float GLOW_RADIUS = BULB_RADIUS * 2.6f;
private static final float GLOW_ALPHA = 0.16f;
private static final String GEO_BULB = "bulb";
private static final String GEO_SOCKET = "socket";
private static final String GEO_GLOW = "glow";
private static final ColorRGBA SEL_COLOR = new ColorRGBA(1f, 1f, 0f, 1f);
private static final ColorRGBA SOCKET_COLOR = new ColorRGBA(0.22f, 0.22f, 0.22f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists: lights[i] <-> markers[i] <-> pointLights[i]
private final List<PlacedLight> lights = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private final List<PointLight> pointLights = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedLight> pendingLights = null;
public LightState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override
protected void cleanup(Application application) {
clearAll();
}
@Override
protected void onEnable() {
if (pendingLights != null) {
loadPlacedLights(pendingLights);
pendingLights = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_LIGHTS) return;
SharedInput.LightClick click;
while ((click = input.lightClickQueue.poll()) != null) {
handleClick(click);
}
SharedInput.LightPropertyChange prop = input.pendingLightProp.getAndSet(null);
if (prop != null && selectedIdx >= 0) {
applyProperty(selectedIdx, prop);
}
if (input.deleteLightRequested) {
input.deleteLightRequested = false;
if (selectedIdx >= 0) removeLight(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.LightClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
int hit = pickMarker(ray);
if (hit >= 0) {
if (click.rightButton()) {
deselect();
} else {
selectLight(hit);
}
return;
}
if (click.rightButton()) {
deselect();
return;
}
// Neues Licht auf dem Terrain platzieren
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
PlacedLight pl = new PlacedLight(pt.x, pt.y + 1f, pt.z, 1f, 1f, 1f, 1f, 20f);
addLight(pl);
selectLight(lights.size() - 1);
}
/** Trifft den Glow-Halo oder den Bulb irgendeines Markers. */
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectLight(int idx) {
deselect();
selectedIdx = idx;
setBulbColor(idx, SEL_COLOR);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < lights.size()) {
PlacedLight l = lights.get(selectedIdx);
setBulbColor(selectedIdx, new ColorRGBA(l.r(), l.g(), l.b(), 1f));
}
selectedIdx = -1;
input.selectedLightInfo = null;
input.lightSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedLight l = lights.get(idx);
input.selectedLightInfo = String.format(
java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f|%.3f",
idx, l.x(), l.y(), l.z(), l.r(), l.g(), l.b(), l.intensity(), l.radius());
input.lightSelectionChanged = true;
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addLight(PlacedLight pl) {
PointLight pt = new PointLight();
pt.setColor(new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f).mult(pl.intensity()));
pt.setRadius(pl.radius());
pt.setPosition(new Vector3f(pl.x(), pl.y(), pl.z()));
rootNode.addLight(pt);
Node marker = buildMarker(pl);
rootNode.attachChild(marker);
lights.add(pl);
markers.add(marker);
pointLights.add(pt);
}
private void removeLight(int idx) {
rootNode.removeLight(pointLights.get(idx));
rootNode.detachChild(markers.get(idx));
lights.remove(idx);
markers.remove(idx);
pointLights.remove(idx);
selectedIdx = -1;
input.selectedLightInfo = null;
input.lightSelectionChanged = true;
}
private void clearAll() {
for (PointLight pl : pointLights) rootNode.removeLight(pl);
for (Node m : markers) rootNode.detachChild(m);
lights.clear();
markers.clear();
pointLights.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, SharedInput.LightPropertyChange prop) {
PlacedLight old = lights.get(idx);
PlacedLight updated = new PlacedLight(
old.x(), old.y(), old.z(),
prop.r(), prop.g(), prop.b(),
prop.intensity(), prop.radius());
lights.set(idx, updated);
PointLight pt = pointLights.get(idx);
pt.setColor(new ColorRGBA(prop.r(), prop.g(), prop.b(), 1f).mult(prop.intensity()));
pt.setRadius(prop.radius());
setGlowColor(idx, prop.r(), prop.g(), prop.b());
setBulbColor(idx, SEL_COLOR); // gelb während Selektion
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
/**
* Glühbirne: Glaskolben (Sphere) + Sockel (Cylinder) + weicher Leucht-Halo (Sphere, transparent).
* Naming convention:
* "bulb" Glaskolben, Farbe = Lichtfarbe (gelb wenn selektiert)
* "socket" Metallsockel, immer dunkelgrau
* "glow" Leuchthalo, immer Lichtfarbe (halbtransparent)
*/
private Node buildMarker(PlacedLight pl) {
ColorRGBA lightColor = new ColorRGBA(pl.r(), pl.g(), pl.b(), 1f);
// Glaskolben
Material bulbMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
bulbMat.setColor("Color", lightColor);
Geometry bulb = new Geometry(GEO_BULB, new Sphere(12, 12, BULB_RADIUS));
bulb.setMaterial(bulbMat);
// Metallsockel (Zylinder unterhalb des Kolbens)
Material socketMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
socketMat.setColor("Color", SOCKET_COLOR);
Geometry socket = new Geometry(GEO_SOCKET,
new Cylinder(4, 10, SOCKET_RADIUS, SOCKET_HEIGHT, true));
socket.setMaterial(socketMat);
socket.rotate(FastMath.HALF_PI, 0, 0);
socket.setLocalTranslation(0, -(BULB_RADIUS + SOCKET_HEIGHT * 0.5f), 0);
// Leuchthalo (halbtransparente Sphere um den Kolben)
Material glowMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
glowMat.setColor("Color", new ColorRGBA(pl.r(), pl.g(), pl.b(), GLOW_ALPHA));
glowMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
glowMat.getAdditionalRenderState().setDepthWrite(false);
glowMat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Front);
Geometry glow = new Geometry(GEO_GLOW, new Sphere(8, 8, GLOW_RADIUS));
glow.setMaterial(glowMat);
glow.setQueueBucket(RenderQueue.Bucket.Transparent);
Node node = new Node("light_node");
node.attachChild(glow); // zuerst → transparent nach opaken
node.attachChild(bulb);
node.attachChild(socket);
node.setLocalTranslation(pl.x(), pl.y(), pl.z());
return node;
}
/** Setzt die Farbe des Glaskolbens (Auswahl-Feedback oder Lichtfarbe). */
private void setBulbColor(int idx, ColorRGBA color) {
findGeo(markers.get(idx), GEO_BULB, geo ->
geo.getMaterial().setColor("Color", color));
}
/** Aktualisiert den Glow-Halo auf die neue Lichtfarbe. */
private void setGlowColor(int idx, float r, float g, float b) {
findGeo(markers.get(idx), GEO_GLOW, geo ->
geo.getMaterial().setColor("Color", new ColorRGBA(r, g, b, GLOW_ALPHA)));
}
private static void findGeo(Node node, String name, java.util.function.Consumer<Geometry> action) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && name.equals(geo.getName())) {
action.accept(geo);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedLight> getPlacedLights() {
return new ArrayList<>(lights);
}
public void loadPlacedLights(List<PlacedLight> loaded) {
if (rootNode == null) {
pendingLights = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedLight pl : loaded) {
addLight(pl);
}
}
}

View File

@@ -0,0 +1,358 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedMusicArea;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.util.ArrayList;
import java.util.List;
public class MusicAreaState extends BaseAppState {
private static final float LINE_OFFSET_Y = 0.35f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.5f, 0.3f, 1f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 0.9f, 0.2f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.8f, 0.5f, 1f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedMusicArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedMusicArea> pendingAreas = null;
public MusicAreaState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingAreas != null) {
loadAreas(pendingAreas);
pendingAreas = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_MUSIC_AREAS) {
if (placing) cancelPoly();
return;
}
SharedInput.MusicAreaClick click;
while ((click = input.musicAreaClickQueue.poll()) != null) {
handleClick(click);
}
PlacedMusicArea pending = input.pendingMusicArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteMusicAreaRequested) {
input.deleteMusicAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.MusicAreaClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
if (click.rightButton()) {
if (placing) closePoly();
else deselect();
return;
}
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
for (int i = 0; i < areas.size(); i++) {
PlacedMusicArea a = areas.get(i);
if (SoundAreaState.pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i);
return;
}
}
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SoundAreaState.SNAP_DIST * SoundAreaState.SNAP_DIST;
float bx = x, bz = z;
for (PlacedMusicArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = a.pointsX()[i]; bz = a.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) { cancelPoly(); return; }
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedMusicArea area = new PlacedMusicArea(xs, zs, "", "", "");
addArea(area);
selectArea(areas.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("music_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("music_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectArea(int idx) {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedMusicArea a = areas.get(idx);
input.selectedMusicAreaInfo = idx + "|" + a.dayTrack() + "|" + a.nightTrack() + "|" + a.combatTrack();
input.musicAreaSelectionChanged = true;
}
// ── Add / Remove / Apply ──────────────────────────────────────────────────
private void addArea(PlacedMusicArea area) {
areas.add(area);
List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ());
Geometry geo = buildLineGeo("music_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
areaGeos.add(geo);
}
private void removeArea(int idx) {
rootNode.detachChild(areaGeos.get(idx));
areas.remove(idx);
areaGeos.remove(idx);
selectedIdx = -1;
input.selectedMusicAreaInfo = null;
input.musicAreaSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : areaGeos) rootNode.detachChild(g);
areas.clear();
areaGeos.clear();
cancelPoly();
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedMusicArea updated) {
if (updated.pointsX().length == 0) {
PlacedMusicArea existing = areas.get(idx);
areas.set(idx, new PlacedMusicArea(
existing.pointsX(), existing.pointsZ(),
updated.dayTrack(), updated.nightTrack(), updated.combatTrack()));
} else {
areas.set(idx, updated);
}
publishSelection(idx);
}
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedMusicArea> getPlacedAreas() {
return new ArrayList<>(areas);
}
public void loadAreas(List<PlacedMusicArea> loaded) {
if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedMusicArea a : loaded) addArea(a);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -3,6 +3,8 @@ package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
@@ -29,6 +31,8 @@ import java.time.format.DateTimeFormatter;
public class PalmGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PalmGeneratorState.class);
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
private final SharedInput input;
@@ -194,9 +198,11 @@ public class PalmGeneratorState extends BaseAppState {
+ DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
File out = modelDir.resolve("Palm_" + stampedName + ".j3o").toFile();
BinaryExporter.getInstance().save(palmNode, out);
log.info("[Palme] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Palme exportiert: " + out.getName();
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Palme] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Palme Export-Fehler: " + e.getMessage();
}
}

View File

@@ -0,0 +1,103 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.shape.Cylinder;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.editor.SharedInput;
public class PlayToolState extends BaseAppState {
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private Geometry spawnMarker;
public PlayToolState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { removeMarker(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_PLAY_TOOL) return;
SharedInput.PlayToolClick click;
while ((click = input.playToolClickQueue.poll()) != null) {
handleClick(click);
}
// Update marker position if spawn changed from text fields
if (!Float.isNaN(input.tempSpawnX) && !Float.isNaN(input.tempSpawnZ)) {
placeMarkerAt(input.tempSpawnX, input.tempSpawnZ);
}
}
private void handleClick(SharedInput.PlayToolClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
input.tempSpawnX = pt.x;
input.tempSpawnZ = pt.z;
input.pickedSpawnInfo = pt.x + "|" + pt.z;
input.spawnPickChanged = true;
placeMarkerAt(pt.x, pt.z);
}
private void placeMarkerAt(float x, float z) {
float y = terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f;
if (Float.isNaN(y)) y = 0f;
if (spawnMarker == null) {
Cylinder cyl = new Cylinder(8, 16, 0.4f, 0.1f, true);
spawnMarker = new Geometry("spawn_marker", cyl);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0f, 1f, 0f, 1f));
spawnMarker.setMaterial(mat);
spawnMarker.rotate(FastMath.HALF_PI, 0, 0);
rootNode.attachChild(spawnMarker);
}
spawnMarker.setLocalTranslation(x, y + 0.05f, z);
}
private void removeMarker() {
if (spawnMarker != null) {
rootNode.detachChild(spawnMarker);
spawnMarker = null;
}
}
}

View File

@@ -0,0 +1,390 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.mesh.IndexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedSoundArea;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.ShortBuffer;
import java.util.ArrayList;
import java.util.List;
public class SoundAreaState extends BaseAppState {
static final float SNAP_DIST = 8f;
private static final float LINE_OFFSET_Y = 0.3f;
private static final ColorRGBA COLOR_NORMAL = new ColorRGBA(0.1f, 0.85f, 0.4f, 1f);
private static final ColorRGBA COLOR_SELECTED = new ColorRGBA(1f, 1f, 0f, 1f);
private static final ColorRGBA COLOR_INPROG = new ColorRGBA(0.3f, 0.9f, 1f, 1f);
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<PlacedSoundArea> areas = new ArrayList<>();
private final List<Geometry> areaGeos = new ArrayList<>();
private int selectedIdx = -1;
// polygon being placed
private boolean placing = false;
private final List<Float> currX = new ArrayList<>();
private final List<Float> currZ = new ArrayList<>();
private Geometry inProgGeo = null;
private Geometry lastPointMarker = null;
private List<PlacedSoundArea> pendingAreas = null;
public SoundAreaState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingAreas != null) {
loadAreas(pendingAreas);
pendingAreas = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_SOUND_AREAS) {
if (placing) cancelPoly();
return;
}
SharedInput.SoundAreaClick click;
while ((click = input.soundAreaClickQueue.poll()) != null) {
handleClick(click);
}
PlacedSoundArea pending = input.pendingSoundArea.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.cancelZoneDrawing) {
input.cancelZoneDrawing = false;
if (placing) cancelPoly();
}
if (input.deleteSoundAreaRequested) {
input.deleteSoundAreaRequested = false;
if (selectedIdx >= 0) removeArea(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.SoundAreaClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
if (click.rightButton()) {
if (placing) closePoly();
else deselect();
return;
}
// get terrain hit
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
float hitX = pt.x, hitZ = pt.z;
if (placing) {
// snap to existing vertex?
float[] snapped = snapVertex(hitX, hitZ);
hitX = snapped[0];
hitZ = snapped[1];
// auto-close: if close to first vertex and ≥3 points
if (currX.size() >= 3) {
float dx = hitX - currX.get(0);
float dz = hitZ - currZ.get(0);
if (dx * dx + dz * dz < SNAP_DIST * SNAP_DIST * 0.25f) {
closePoly();
return;
}
}
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
} else {
// try to select existing area
for (int i = 0; i < areas.size(); i++) {
PlacedSoundArea a = areas.get(i);
if (pointInPolygon(hitX, hitZ, a.pointsX(), a.pointsZ())) {
selectArea(i);
return;
}
}
// no area hit start new polygon
deselect();
placing = true;
currX.clear();
currZ.clear();
currX.add(hitX);
currZ.add(hitZ);
updateInProgressGeo();
}
}
private float[] snapVertex(float x, float z) {
float bestDist2 = SNAP_DIST * SNAP_DIST;
float bx = x, bz = z;
for (PlacedSoundArea a : areas) {
for (int i = 0; i < a.pointsX().length; i++) {
float dx = x - a.pointsX()[i];
float dz = z - a.pointsZ()[i];
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = a.pointsX()[i]; bz = a.pointsZ()[i]; }
}
}
for (int i = 0; i < currX.size(); i++) {
float dx = x - currX.get(i);
float dz = z - currZ.get(i);
float d2 = dx * dx + dz * dz;
if (d2 < bestDist2) { bestDist2 = d2; bx = currX.get(i); bz = currZ.get(i); }
}
return new float[]{bx, bz};
}
private void closePoly() {
if (currX.size() < 3) {
cancelPoly();
return;
}
float[] xs = toArray(currX);
float[] zs = toArray(currZ);
PlacedSoundArea area = new PlacedSoundArea(xs, zs, "", 1f, false);
addArea(area);
selectArea(areas.size() - 1);
cancelPoly();
}
private void cancelPoly() {
placing = false;
currX.clear();
currZ.clear();
if (inProgGeo != null) { rootNode.detachChild(inProgGeo); inProgGeo = null; }
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
}
// ── In-progress visual ────────────────────────────────────────────────────
private void updateInProgressGeo() {
if (inProgGeo != null) rootNode.detachChild(inProgGeo);
int n = currX.size();
if (n == 0) { inProgGeo = null; updateLastPointMarker(); return; }
inProgGeo = buildLineGeo("sound_inprog", currX, currZ, COLOR_INPROG, Mesh.Mode.LineStrip);
rootNode.attachChild(inProgGeo);
updateLastPointMarker();
}
private void updateLastPointMarker() {
if (lastPointMarker != null) { rootNode.detachChild(lastPointMarker); lastPointMarker = null; }
if (currX.isEmpty()) return;
float x = currX.get(currX.size() - 1);
float z = currZ.get(currZ.size() - 1);
float y = (terrain != null ? terrain.getHeight(new Vector2f(x, z)) : 0f) + LINE_OFFSET_Y + 0.05f;
float s = 1.5f;
FloatBuffer buf = BufferUtils.createFloatBuffer(4 * 3);
buf.put(x - s).put(y).put(z - s); buf.put(x + s).put(y).put(z + s);
buf.put(x - s).put(y).put(z + s); buf.put(x + s).put(y).put(z - s);
buf.flip();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, buf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 1f));
mat.getAdditionalRenderState().setLineWidth(3f);
lastPointMarker = new Geometry("sound_lastpoint", mesh);
lastPointMarker.setMaterial(mat);
rootNode.attachChild(lastPointMarker);
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectArea(int idx) {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = idx;
areaGeos.get(idx).getMaterial().setColor("Color", COLOR_SELECTED);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < areaGeos.size()) {
areaGeos.get(selectedIdx).getMaterial().setColor("Color", COLOR_NORMAL);
}
selectedIdx = -1;
input.selectedSoundAreaInfo = null;
input.soundAreaSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedSoundArea a = areas.get(idx);
input.selectedSoundAreaInfo = idx + "|" + a.soundPath() + "|" + a.volume() + "|" + a.crossfade();
input.soundAreaSelectionChanged = true;
}
// ── Add / Remove / Apply ──────────────────────────────────────────────────
private void addArea(PlacedSoundArea area) {
areas.add(area);
List<Float> xs = toList(area.pointsX());
List<Float> zs = toList(area.pointsZ());
Geometry geo = buildLineGeo("sound_area_" + (areas.size() - 1), xs, zs, COLOR_NORMAL, Mesh.Mode.LineLoop);
rootNode.attachChild(geo);
areaGeos.add(geo);
}
private void removeArea(int idx) {
rootNode.detachChild(areaGeos.get(idx));
areas.remove(idx);
areaGeos.remove(idx);
selectedIdx = -1;
input.selectedSoundAreaInfo = null;
input.soundAreaSelectionChanged = true;
}
private void clearAll() {
for (Geometry g : areaGeos) rootNode.detachChild(g);
areas.clear();
areaGeos.clear();
cancelPoly();
selectedIdx = -1;
}
private void applyProperty(int idx, PlacedSoundArea updated) {
if (updated.pointsX().length == 0) {
// zero-length polygon = only update metadata, keep existing polygon
PlacedSoundArea existing = areas.get(idx);
areas.set(idx, new PlacedSoundArea(
existing.pointsX(), existing.pointsZ(),
updated.soundPath(), updated.volume(), updated.crossfade()));
} else {
areas.set(idx, updated);
}
publishSelection(idx);
}
// ── Line geometry builder ─────────────────────────────────────────────────
private Geometry buildLineGeo(String name, List<Float> xs, List<Float> zs,
ColorRGBA color, Mesh.Mode mode) {
int n = xs.size();
FloatBuffer posBuffer = BufferUtils.createFloatBuffer(n * 3);
for (int i = 0; i < n; i++) {
float hy = terrain != null ? terrain.getHeight(new Vector2f(xs.get(i), zs.get(i))) : 0f;
posBuffer.put(xs.get(i)).put(hy + LINE_OFFSET_Y).put(zs.get(i));
}
posBuffer.flip();
Mesh mesh = new Mesh();
mesh.setMode(mode);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuffer);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", color);
mat.getAdditionalRenderState().setLineWidth(2f);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
return geo;
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedSoundArea> getPlacedAreas() {
return new ArrayList<>(areas);
}
public void loadAreas(List<PlacedSoundArea> loaded) {
if (rootNode == null) {
pendingAreas = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedSoundArea a : loaded) addArea(a);
}
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
static boolean pointInPolygon(float px, float pz, float[] xs, float[] zs) {
int n = xs.length;
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
float xi = xs[i], zi = zs[i];
float xj = xs[j], zj = zs[j];
if ((zi > pz) != (zj > pz) && (px < (xj - xi) * (pz - zi) / (zj - zi) + xi)) {
inside = !inside;
}
}
return inside;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static float[] toArray(List<Float> list) {
float[] a = new float[list.size()];
for (int i = 0; i < list.size(); i++) a[i] = list.get(i);
return a;
}
private static List<Float> toList(float[] arr) {
List<Float> l = new ArrayList<>(arr.length);
for (float f : arr) l.add(f);
return l;
}
}

View File

@@ -52,6 +52,8 @@ import de.blight.editor.FrameTransfer;
import de.blight.editor.SharedInput;
import de.blight.editor.tree.TreeMeshBuilder;
import de.blight.editor.tree.TreeParams;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* JME3-Zustand für den prozeduralen Baum-Generator.
@@ -64,6 +66,8 @@ import de.blight.editor.tree.TreeParams;
*/
public class TreeGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(TreeGeneratorState.class);
private static final int IMPOSTOR_SIZE = 512;
private static final int PREVIEW_SIZE = 1024;
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve("editor-assets");
@@ -563,12 +567,12 @@ public class TreeGeneratorState extends BaseAppState {
while (treeNode.getNumControls() > 0)
treeNode.removeControl(treeNode.getControl(0));
BinaryExporter.getInstance().save(treeNode, out);
log.info("[Blight-Baum] Gespeichert: {}", out.getAbsolutePath());
input.treeGenStatusMsg = "Exportiert: " + out.getName();
input.refreshAssets = true;
System.out.println("[TreeGenerator] Exportiert: " + out.getAbsolutePath());
} catch (IOException e) {
log.error("[Blight-Baum] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Export-Fehler: " + e.getMessage();
System.err.println("[TreeGenerator] Export-Fehler: " + e.getMessage());
}
}

View File

@@ -0,0 +1,273 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.collision.CollisionResults;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.*;
import com.jme3.scene.shape.Quad;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedWater;
import de.blight.editor.SharedInput;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
public class WaterBodyState extends BaseAppState {
private static final ColorRGBA WATER_COLOR = new ColorRGBA(0.05f, 0.25f, 0.70f, 0.52f);
private static final ColorRGBA BORDER_COLOR = new ColorRGBA(0.30f, 0.60f, 1.00f, 0.85f);
private static final ColorRGBA BORDER_SEL = new ColorRGBA(1.00f, 1.00f, 0.00f, 1.00f);
private static final String GEO_SURFACE = "water_surface";
private static final String GEO_BORDER = "water_border";
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
// parallel lists
private final List<PlacedWater> bodies = new ArrayList<>();
private final List<Node> markers = new ArrayList<>();
private int selectedIdx = -1;
private List<PlacedWater> pendingBodies = null;
public WaterBodyState(SharedInput input) {
this.input = input;
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
}
@Override protected void cleanup(Application application) { clearAll(); }
@Override
protected void onEnable() {
if (pendingBodies != null) {
loadPlacedBodies(pendingBodies);
pendingBodies = null;
}
}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.activeLayer != SharedInput.LAYER_WATER) return;
SharedInput.WaterClick click;
while ((click = input.waterClickQueue.poll()) != null) {
handleClick(click);
}
PlacedWater pending = input.pendingWater.getAndSet(null);
if (pending != null && selectedIdx >= 0) {
applyProperty(selectedIdx, pending);
}
if (input.deleteWaterRequested) {
input.deleteWaterRequested = false;
if (selectedIdx >= 0) removeBody(selectedIdx);
}
}
// ── Click handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.WaterClick click) {
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
int hit = pickMarker(ray);
if (hit >= 0) {
if (click.rightButton()) deselect();
else selectBody(hit);
return;
}
if (click.rightButton()) { deselect(); return; }
if (terrain == null) return;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
addBody(new PlacedWater(pt.x, pt.y + 0.05f, pt.z, 30f, 30f));
selectBody(bodies.size() - 1);
}
private int pickMarker(Ray ray) {
for (int i = 0; i < markers.size(); i++) {
CollisionResults res = new CollisionResults();
markers.get(i).collideWith(ray, res);
if (res.size() > 0) return i;
}
return -1;
}
// ── Selection ─────────────────────────────────────────────────────────────
private void selectBody(int idx) {
deselect();
selectedIdx = idx;
setBorderColor(idx, BORDER_SEL);
publishSelection(idx);
}
private void deselect() {
if (selectedIdx >= 0 && selectedIdx < bodies.size()) {
setBorderColor(selectedIdx, BORDER_COLOR);
}
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
private void publishSelection(int idx) {
PlacedWater b = bodies.get(idx);
input.selectedWaterInfo = String.format(java.util.Locale.ROOT,
"%d|%.3f|%.3f|%.3f|%.3f|%.3f",
idx, b.x(), b.y(), b.z(), b.width(), b.depth());
input.waterSelectionChanged = true;
}
// ── Add / Remove ──────────────────────────────────────────────────────────
private void addBody(PlacedWater b) {
Node marker = buildMarker(b);
rootNode.attachChild(marker);
markers.add(marker);
bodies.add(b);
}
private void removeBody(int idx) {
rootNode.detachChild(markers.get(idx));
bodies.remove(idx);
markers.remove(idx);
selectedIdx = -1;
input.selectedWaterInfo = null;
input.waterSelectionChanged = true;
}
private void clearAll() {
for (Node m : markers) rootNode.detachChild(m);
bodies.clear();
markers.clear();
selectedIdx = -1;
}
// ── Property application ──────────────────────────────────────────────────
private void applyProperty(int idx, PlacedWater updated) {
rootNode.detachChild(markers.get(idx));
Node newMarker = buildMarker(updated);
setBorderColorOnNode(newMarker, BORDER_SEL);
rootNode.attachChild(newMarker);
markers.set(idx, newMarker);
bodies.set(idx, updated);
publishSelection(idx);
}
// ── Marker visuals ────────────────────────────────────────────────────────
private Node buildMarker(PlacedWater b) {
// Water surface (semi-transparent quad)
Quad quad = new Quad(b.width(), b.depth());
Geometry surface = new Geometry(GEO_SURFACE, quad);
Material waterMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
waterMat.setColor("Color", WATER_COLOR);
waterMat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
waterMat.getAdditionalRenderState().setDepthWrite(false);
surface.setMaterial(waterMat);
surface.setQueueBucket(RenderQueue.Bucket.Transparent);
surface.rotate(-FastMath.HALF_PI, 0, 0);
surface.setLocalTranslation(-b.width() * 0.5f, 0f, b.depth() * 0.5f);
// Border outline (Line mesh forming a rectangle)
Geometry border = new Geometry(GEO_BORDER, buildBorderMesh(b.width(), b.depth()));
Material borderMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
borderMat.setColor("Color", BORDER_COLOR);
borderMat.getAdditionalRenderState().setDepthTest(false);
border.setMaterial(borderMat);
Node node = new Node("water_node");
node.attachChild(surface);
node.attachChild(border);
node.setLocalTranslation(b.x(), b.y(), b.z());
return node;
}
private static Mesh buildBorderMesh(float w, float d) {
// 4 corner points at +0.02 above water surface (local coords, XZ plane)
float hw = w * 0.5f, hd = d * 0.5f, y = 0.02f;
FloatBuffer pos = BufferUtils.createFloatBuffer(4 * 3);
pos.put(-hw).put(y).put(-hd);
pos.put( hw).put(y).put(-hd);
pos.put( hw).put(y).put( hd);
pos.put(-hw).put(y).put( hd);
IntBuffer idx = BufferUtils.createIntBuffer(8); // 4 edges
idx.put(0).put(1).put(1).put(2).put(2).put(3).put(3).put(0);
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 2, idx);
mesh.updateBound();
return mesh;
}
private void setBorderColor(int idx, ColorRGBA color) {
setBorderColorOnNode(markers.get(idx), color);
}
private static void setBorderColorOnNode(Node node, ColorRGBA color) {
for (Spatial child : node.getChildren()) {
if (child instanceof Geometry geo && GEO_BORDER.equals(geo.getName())) {
geo.getMaterial().setColor("Color", color);
return;
}
}
}
// ── Save / Load ───────────────────────────────────────────────────────────
public List<PlacedWater> getPlacedBodies() {
return new ArrayList<>(bodies);
}
public void loadPlacedBodies(List<PlacedWater> loaded) {
if (rootNode == null) {
pendingBodies = new ArrayList<>(loaded);
return;
}
clearAll();
for (PlacedWater b : loaded) addBody(b);
}
}

View File

@@ -25,8 +25,10 @@ public class HeightTool extends EditorTool {
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 50.0, 1.0, 500.0);
public final ToolParameter brushStrength = new ToolParameter("Pinselstärke", 2.0, 0.1, 50.0);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 50.0, 1.0, 500.0);
public final ToolParameter brushStrength = new ToolParameter("Pinselstärke", 2.0, 0.1, 50.0);
public final ToolParameter plateauHeight = new ToolParameter("Plateau-Höhe", 0.0, -500.0, 500.0);
public volatile boolean plateauHeightChanged = false;
@Override
public String getName() { return "Höhe"; }

View File

@@ -0,0 +1,119 @@
package de.blight.editor.ui;
import javafx.geometry.Insets;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
/**
* Dialog zur Auswahl einer JME-Material-Definition (.j3md).
*
* Zeigt JME-Standard-Materialien und eigene MatDefs aus dem Asset-Verzeichnis.
* Gibt den relativen Asset-Pfad (z.B. {@code Common/MatDefs/Light/Lighting.j3md}
* oder {@code MatDefs/Grass.j3md}) zurück, oder {@code null} bei Abbruch.
*/
public class MaterialChooser extends Dialog<String> {
private static final List<String> JME_MATERIALS = List.of(
"Common/MatDefs/Misc/Unshaded.j3md",
"Common/MatDefs/Light/Lighting.j3md",
"Common/MatDefs/Light/PBRLighting.j3md"
);
private final ToggleGroup toggleGroup = new ToggleGroup();
private final List<ToggleButton> allButtons = new ArrayList<>();
private String selectedPath;
/**
* @param matDefsRoot Verzeichnis mit eigenen .j3md-Dateien (darf {@code null} sein)
*/
public MaterialChooser(Path matDefsRoot) {
setTitle("Material auswählen");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
VBox contentBox = new VBox(12);
contentBox.setPadding(new Insets(4));
contentBox.getChildren().add(buildSection("JME Standard", JME_MATERIALS));
if (matDefsRoot != null && Files.isDirectory(matDefsRoot)) {
List<String> custom = new ArrayList<>();
try (Stream<Path> walk = Files.walk(matDefsRoot)) {
walk.filter(p -> p.toString().toLowerCase().endsWith(".j3md"))
.sorted()
.forEach(p -> custom.add("MatDefs/" + p.getFileName().toString()));
} catch (Exception ignored) {}
if (!custom.isEmpty())
contentBox.getChildren().add(buildSection("Eigene MatDefs", custom));
}
TextField filterField = new TextField();
filterField.setPromptText("Filtern...");
filterField.textProperty().addListener(
(obs, o, n) -> applyFilter(n == null ? "" : n.toLowerCase()));
ScrollPane scroll = new ScrollPane(contentBox);
scroll.setFitToWidth(true);
scroll.setPrefSize(460, 380);
scroll.setStyle("-fx-background-color: transparent;");
VBox root = new VBox(8, filterField, scroll);
root.setPadding(new Insets(10));
getDialogPane().setContent(root);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setText("OK");
okBtn.setDisable(true);
toggleGroup.selectedToggleProperty().addListener((obs, o, n) -> {
okBtn.setDisable(n == null);
selectedPath = (n instanceof ToggleButton tb) ? (String) tb.getUserData() : null;
});
setResultConverter(btn -> btn == ButtonType.OK ? selectedPath : null);
}
private VBox buildSection(String title, List<String> paths) {
Label lbl = new Label(title);
lbl.setStyle("-fx-font-weight: bold; -fx-font-size: 12;");
VBox items = new VBox(3);
for (String path : paths) {
String name = path.substring(path.lastIndexOf('/') + 1);
if (name.toLowerCase().endsWith(".j3md"))
name = name.substring(0, name.length() - 5);
ToggleButton btn = new ToggleButton(name);
btn.setUserData(path);
btn.setToggleGroup(toggleGroup);
btn.setMaxWidth(Double.MAX_VALUE);
btn.setTooltip(new Tooltip(path));
btn.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
selectedPath = path;
Button ok = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (ok != null) ok.fire();
}
});
items.getChildren().add(btn);
allButtons.add(btn);
}
return new VBox(4, lbl, new Separator(), items);
}
private void applyFilter(String lower) {
for (ToggleButton btn : allButtons) {
String path = (String) btn.getUserData();
boolean vis = lower.isBlank() || path.toLowerCase().contains(lower);
btn.setVisible(vis);
btn.setManaged(vis);
}
}
}

View File

@@ -0,0 +1,253 @@
package de.blight.editor.ui;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.*;
import javafx.stage.Modality;
import java.io.InputStream;
import java.nio.file.*;
import java.util.*;
import java.util.stream.Stream;
/**
* Photo-explorer-style texture picker dialog.
*
* Shows user-imported textures from {@code assetRoot/textures/} and optionally
* JME built-in terrain textures from the classpath (jme3-testdata).
* Returns the selected JME asset path (relative to asset root for user textures,
* classpath path for JME textures), or {@code null} when cancelled.
*
* Usage:
* <pre>
* new TextureChooser(assetRoot, true)
* .showAndWait()
* .ifPresent(path -> { ... });
* </pre>
*/
public class TextureChooser extends Dialog<String> {
private static final int THUMB_SIZE = 80;
private static final int CARD_W = THUMB_SIZE + 20;
private static final int CARD_H = THUMB_SIZE + 38;
private static final Set<String> IMAGE_EXTS = Set.of(
".jpg", ".jpeg", ".png", ".bmp", ".tga", ".dds");
/** Curated list of JME built-in terrain diffuse textures (jme3-testdata). */
private static final List<String> JME_TEXTURES = List.of(
"Textures/Terrain/splat/grass.jpg",
"Textures/Terrain/splat/dirt.jpg",
"Textures/Terrain/splat/road.jpg",
"Textures/Terrain/Rock2/rock.jpg",
"Textures/Terrain/Rocky/RockyTexture.jpg",
"Textures/Terrain/BrickWall/BrickWall.jpg",
"Textures/Terrain/Pond/Pond.jpg",
"Textures/Terrain/Rock/Rock.PNG",
"Textures/Terrain/PBR/Gravel015_1K_Color.png",
"Textures/Terrain/PBR/Ground036_1K_Color.png",
"Textures/Terrain/PBR/Ground037_1K_Color.png",
"Textures/Terrain/PBR/Marble013_1K_Color.png",
"Textures/Terrain/PBR/Rock035_1K_Color.png",
"Textures/Terrain/PBR/Snow006_1K_Color.png",
"Textures/Terrain/PBR/Tiles083_1K_Color.png"
);
private final ToggleGroup toggleGroup = new ToggleGroup();
private final VBox contentBox = new VBox(16);
private final TextField filterField = new TextField();
private final List<ToggleButton> allCards = new ArrayList<>();
private String selectedPath;
/**
* @param assetRoot path to the editor-assets directory (may be {@code null} to skip user textures)
* @param includeJmeBuiltin whether to include JME built-in terrain textures
*/
public TextureChooser(Path assetRoot, boolean includeJmeBuiltin) {
setTitle("Textur auswählen");
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
contentBox.setPadding(new Insets(4));
// ── User-imported textures ────────────────────────────────────────────
if (assetRoot != null) {
Path texturesDir = assetRoot.resolve("Textures");
if (Files.isDirectory(texturesDir)) {
FlowPane userPane = newFlowPane();
addUserTextures(userPane, texturesDir, assetRoot);
if (!userPane.getChildren().isEmpty()) {
contentBox.getChildren().add(section("Eigene Texturen", userPane));
}
}
}
// ── JME built-in textures ─────────────────────────────────────────────
if (includeJmeBuiltin) {
FlowPane jmePane = newFlowPane();
for (String jmePath : JME_TEXTURES) {
ToggleButton card = buildJmeCard(jmePath);
jmePane.getChildren().add(card);
allCards.add(card);
}
if (!jmePane.getChildren().isEmpty()) {
contentBox.getChildren().add(section("JME Texturen", jmePane));
}
}
// ── Filter ────────────────────────────────────────────────────────────
filterField.setPromptText("Filtern...");
filterField.textProperty().addListener(
(obs, o, n) -> applyFilter(n == null ? "" : n.toLowerCase()));
ScrollPane scroll = new ScrollPane(contentBox);
scroll.setFitToWidth(true);
scroll.setPrefSize(680, 440);
scroll.setStyle("-fx-background-color: transparent;");
VBox root = new VBox(8, filterField, scroll);
root.setPadding(new Insets(10));
getDialogPane().setContent(root);
getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
okBtn.setText("OK");
okBtn.setDisable(true);
// ── Selection tracking ────────────────────────────────────────────────
toggleGroup.selectedToggleProperty().addListener((obs, o, n) -> {
okBtn.setDisable(n == null);
selectedPath = (n instanceof ToggleButton tb) ? (String) tb.getUserData() : null;
});
setResultConverter(btn -> btn == ButtonType.OK ? selectedPath : null);
}
// ── Builder helpers ───────────────────────────────────────────────────────
private static FlowPane newFlowPane() {
FlowPane fp = new FlowPane(8, 8);
fp.setPadding(new Insets(6, 4, 4, 4));
return fp;
}
private static VBox section(String title, FlowPane pane) {
Label lbl = new Label(title);
lbl.setStyle("-fx-font-weight: bold; -fx-font-size: 12;");
Separator sep = new Separator();
VBox box = new VBox(4, lbl, sep, pane);
return box;
}
// ── User textures ─────────────────────────────────────────────────────────
private void addUserTextures(FlowPane pane, Path texturesDir, Path assetRoot) {
try (Stream<Path> walk = Files.walk(texturesDir)) {
walk.filter(Files::isRegularFile)
.filter(p -> {
String low = p.getFileName().toString().toLowerCase();
return IMAGE_EXTS.stream().anyMatch(low::endsWith);
})
.sorted()
.forEach(file -> {
String assetPath = assetRoot.relativize(file)
.toString().replace('\\', '/');
Image img;
try {
img = new Image(file.toUri().toString(),
THUMB_SIZE, THUMB_SIZE, true, true, true);
} catch (Exception e) {
img = null;
}
String name = file.getFileName().toString();
ToggleButton card = buildCard(name, assetPath, img);
pane.getChildren().add(card);
allCards.add(card);
});
} catch (Exception ignored) {}
}
// ── JME textures ──────────────────────────────────────────────────────────
private ToggleButton buildJmeCard(String jmePath) {
Image img = null;
try {
InputStream is = TextureChooser.class.getClassLoader()
.getResourceAsStream(jmePath);
if (is != null) {
try (is) {
img = new Image(is, THUMB_SIZE, THUMB_SIZE, true, true);
}
}
} catch (Exception ignored) {}
String name = jmePath.substring(jmePath.lastIndexOf('/') + 1);
return buildCard(name, jmePath, img);
}
// ── Card factory ──────────────────────────────────────────────────────────
private ToggleButton buildCard(String displayName, String assetPath, Image img) {
ImageView iv = new ImageView(img);
iv.setFitWidth(THUMB_SIZE);
iv.setFitHeight(THUMB_SIZE);
iv.setPreserveRatio(true);
// Checkerboard background for transparency indication
iv.setStyle("-fx-background-color: #cccccc;");
Label lbl = new Label(truncate(displayName, 13));
lbl.setMaxWidth(CARD_W - 4);
lbl.setAlignment(Pos.CENTER);
lbl.setStyle("-fx-font-size: 10;");
VBox graphic = new VBox(4, iv, lbl);
graphic.setAlignment(Pos.TOP_CENTER);
graphic.setPrefSize(THUMB_SIZE, THUMB_SIZE + 18);
ToggleButton btn = new ToggleButton();
btn.setGraphic(graphic);
btn.setUserData(assetPath);
btn.setToggleGroup(toggleGroup);
btn.setPrefSize(CARD_W, CARD_H);
btn.setTooltip(new Tooltip(assetPath));
btn.setOnMouseClicked(e -> {
if (e.getClickCount() == 2) {
selectedPath = assetPath;
Button okBtn = (Button) getDialogPane().lookupButton(ButtonType.OK);
if (okBtn != null) okBtn.fire();
}
});
return btn;
}
// ── Filter ────────────────────────────────────────────────────────────────
private void applyFilter(String lowerFilter) {
for (ToggleButton card : allCards) {
String path = (String) card.getUserData();
boolean visible = lowerFilter.isBlank()
|| path.toLowerCase().contains(lowerFilter);
card.setVisible(visible);
card.setManaged(visible);
}
}
// ── Utilities ─────────────────────────────────────────────────────────────
private static String truncate(String s, int max) {
if (s.length() <= max) return s;
int dot = s.lastIndexOf('.');
if (dot > 0 && s.length() - dot <= 5) {
String ext = s.substring(dot);
int keep = max - ext.length() - 1;
return (keep > 0 ? s.substring(0, keep) : "") + "" + ext;
}
return s.substring(0, max - 1) + "";
}
}

View File

@@ -0,0 +1,22 @@
/* Globale Textfarben verhindert weiße Labels bei dunklen System-Themes.
Inline-Styles (setStyle) haben höhere Spezifität und überschreiben diese Regeln. */
.label {
-fx-text-fill: #111111;
}
.check-box .text {
-fx-fill: #111111;
}
.radio-button .text {
-fx-fill: #111111;
}
.toggle-button {
-fx-text-fill: #111111;
}
.titled-pane > .title > .text {
-fx-fill: #111111;
}

View File

@@ -0,0 +1,30 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/blight-editor.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/blight-editor.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%logger{30}] %msg%n%ex</pattern>
</encoder>
</appender>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" bekanntes JME-Verhalten, kein Fehler -->
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>