Animations-Import, Massenimport-Queue, Asset-Archivierung, Voxel-Refactor

- Animations-Import: GLB wird direkt vom Ursprungspfad geladen (kein Zwischenkopieren), J3O in clips/ gespeichert
- RetargetingSystem: Translations-Tracks im Full-Retarget-Pfad erhalten (Hips-Y für sit_down)
- AnimationLibrary: lädt nur J3O, Clip-Name wird bei applyTo() auf Library-Key umbenannt
- SharedInput: animPreviewAddAnimPath → ConcurrentLinkedQueue animImportQueue (Massenimport-Fix)
- EditorApp: archiveOriginal() archiviert Originaldateien nach assets/imported/<assettyp>/
- EditorApp: Animations-Unterknoten im Asset-Baum zeigen enthaltene Clip-Namen
- Neue Animations-Clips: sit_down, get_up_sitting, sitting, pickup, sprinting u.a.
- Voxel: VoxelChunkState entfernt, VoxelChunkNode/MarchingCubes überarbeitet
- Map: Voxel-Chunks bereinigt, Terrain-Chunks aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:52:04 +02:00
parent a369647e9c
commit c8f1dd9432
239 changed files with 8234 additions and 658 deletions

View File

@@ -15,6 +15,7 @@ application {
'--add-opens', 'java.base/java.lang=ALL-UNNAMED',
'--add-opens', 'java.desktop/sun.awt=ALL-UNNAMED',
"-Djava.library.path=${buildDir}/natives",
'-Xmx3g',
]
}

View File

@@ -80,7 +80,7 @@ public class EditorApp extends Application {
private VBox assetPanel;
private MapObjectsView mapObjectsView;
private StackPane worldViewport;
private javafx.scene.canvas.Canvas minimapCanvas;
private javafx.scene.canvas.Canvas compassCanvas;
private VBox topBar; // MenuBar + aktuelle Toolbar
private ToolBar worldToolBar; // Welt-Editor-Toolbar (Layer-Buttons)
private ImageView treePreviewView; // aktualisiert wenn JME3 Bild neu erstellt
@@ -151,11 +151,15 @@ public class EditorApp extends Application {
// AnimSet-Editor
private ListView<String> animSetClipListView;
private ListView<String> animSetActionListView;
private ListView<String> animSetSinkListView;
private ListView<String> animSetAnchorBoneListView;
private String animSetPendingPlayClip = null;
private ComboBox<String> animSetModelCombo;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
private boolean animSetDirty = false;
private String animSetCurrentName = null;
private Path animSetCurrentDir = null;
private java.util.List<String> animJointNames = new java.util.ArrayList<>();
private Label animSetBonesLabel;
// Character-Editor-Zustand
private de.blight.editor.ui.DialogEditorView dialogEditorView;
@@ -238,6 +242,11 @@ public class EditorApp extends Application {
private final java.util.List<de.blight.common.ModelMeta.AttachedEmitter> modelEditorEmitters = new java.util.ArrayList<>();
private javafx.scene.layout.VBox modelEditorLightBox = null;
private javafx.scene.layout.VBox modelEditorEmitterBox = null;
private ComboBox<de.blight.common.model.InteractableType> modelEditorInteractableCB = null;
private Spinner<Double> modelEditorInteractableXSpin = null;
private Spinner<Double> modelEditorInteractableYSpin = null;
private Spinner<Double> modelEditorInteractableZSpin = null;
private boolean updatingInteractableSpinnersFromJme = false;
// Modell-Import-Zustand
private Label modelImportLod1StatusLabel;
@@ -443,6 +452,19 @@ public class EditorApp extends Application {
animClipListView.getItems().setAll(newClips);
if (!newClips.isEmpty()) animClipListView.getSelectionModel().selectFirst();
}
java.util.List<String> newJoints = input.animPreviewJointNames.getAndSet(null);
if (newJoints != null) {
animJointNames = new java.util.ArrayList<>(newJoints);
if (animSetBonesLabel != null) {
if (animJointNames.isEmpty()) {
animSetBonesLabel.setText("Kein Armature gefunden");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #c66;");
} else {
animSetBonesLabel.setText(animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #6a6;");
}
}
}
// AnimSet-Editor: nach Clip-Load automatisch abspielen
if (newClips != null && animSetPendingPlayClip != null) {
input.animPreviewPlayClip = animSetPendingPlayClip;
@@ -457,6 +479,11 @@ public class EditorApp extends Application {
input.animPreviewStatus = null;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText(animStatus);
}
if (input.animImportCompleted) {
input.animImportCompleted = false;
refreshAddAnimCombo(addAnimComboField);
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
}
String animOp = input.animOpStatus;
if (animOp != null) {
@@ -475,6 +502,19 @@ public class EditorApp extends Application {
randomTreeStatusLabel.setText(rts);
}
// Modell-Editor: Ruhepunkt-Spinner nach Raycast-Klick (JME→JFX)
if (input.modelInteractablePosSetFromJme && modelEditorInteractableXSpin != null) {
input.modelInteractablePosSetFromJme = false;
updatingInteractableSpinnersFromJme = true;
modelEditorInteractableXSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetX * 100.0) / 100.0);
modelEditorInteractableYSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetY * 100.0) / 100.0);
modelEditorInteractableZSpin.getValueFactory().setValue(
Math.round(input.modelInteractableOffsetZ * 100.0) / 100.0);
updatingInteractableSpinnersFromJme = false;
}
if (input.refreshAssets) {
input.refreshAssets = false;
boolean pe = shouldPreserveExpansion();
@@ -527,7 +567,7 @@ public class EditorApp extends Application {
updateWaterHeightDisplay(input.waterCurrentHeight);
}
drawMinimap();
drawCompass();
// Spiel-Konsole: gepufferte Zeilen gebündelt ausgeben (max 200 auf einmal)
if (!consoleBuffer.isEmpty() && gameConsoleArea != null) {
@@ -4762,6 +4802,38 @@ public class EditorApp extends Application {
} catch (IOException ignored) {}
}
/** Liest die AnimClip-Namen aus einer J3O-Datei, ohne den JME3-Thread zu benötigen. */
private List<String> readAnimClipNames(Path j3oPath) {
try {
com.jme3.export.binary.BinaryImporter imp = new com.jme3.export.binary.BinaryImporter();
com.jme3.scene.Spatial s = (com.jme3.scene.Spatial) imp.load(j3oPath.toFile());
com.jme3.anim.AnimComposer ac =
de.blight.game.animation.RetargetingSystem.findAnimComposer(s);
if (ac == null) return List.of();
List<String> names = new ArrayList<>(ac.getAnimClipsNames());
names.sort(String.CASE_INSENSITIVE_ORDER);
return names;
} catch (Exception e) {
return List.of();
}
}
/** Hängt Clip-Namen als Unterknoten unter jeden J3O-Eintrag im Animations-Teilbaum. */
private void addAnimClipSubNodes(TreeItem<String> node) {
for (TreeItem<String> child : node.getChildren()) {
Path p = itemPaths.get(child);
if (p != null && !Files.isDirectory(p)
&& p.getFileName().toString().toLowerCase().endsWith(".j3o")) {
child.getChildren().clear();
for (String clip : readAnimClipNames(p)) {
child.getChildren().add(new TreeItem<>(clip));
}
} else if (p != null && Files.isDirectory(p)) {
addAnimClipSubNodes(child);
}
}
}
/**
* Löscht Thumbnail und Impostor-Textur, die zu einer .j3o-Datei gehören.
* Impostor-Dateien werden anhand des Zeitstempel-Suffixes (_YYYYMMDD_HHMMSS) ermittelt.
@@ -4835,7 +4907,10 @@ public class EditorApp extends Application {
loadJmeTexturesInto(jmeTexturesNode);
}
case "audio" -> audioNode = node;
case "animations" -> animationsNode = node;
case "animations" -> {
animationsNode = node;
addAnimClipSubNodes(node);
}
case "items" -> itemsNode = node;
}
}
@@ -4849,6 +4924,7 @@ public class EditorApp extends Application {
catNode.getChildren().clear();
Path dir = itemPaths.get(catNode);
if (dir != null) loadAssetsRecursive(catNode, dir);
if (catNode == animationsNode) addAnimClipSubNodes(catNode);
if (catNode == modelsNode && jmeModelsNode != null)
catNode.getChildren().add(jmeModelsNode);
if (catNode == texturesNode && jmeTexturesNode != null)
@@ -5025,6 +5101,7 @@ public class EditorApp extends Application {
String subDir = isModel ? "Models" : isAudio ? "audio" : "Textures";
TreeItem<String> parent = isModel ? modelsNode : isAudio ? audioNode : texturesNode;
archiveOriginal(file, isModel ? "models" : isAudio ? "audio" : "textures");
try {
Path destDir = ASSET_ROOT.resolve(subDir);
@@ -5663,7 +5740,15 @@ public class EditorApp extends Application {
input.modelEditorPivotY = meta.pivotOffsetY();
input.modelEditorOpenPath = relPath;
root.setRight(buildModelEditorPanel(relPath, absolutePath, meta));
VBox modelEditorInner = buildModelEditorPanel(relPath, absolutePath, meta);
ScrollPane modelEditorScroll = new ScrollPane(modelEditorInner);
modelEditorScroll.setFitToWidth(true);
modelEditorScroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
modelEditorScroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;");
VBox modelEditorOuter = new VBox(modelEditorScroll);
VBox.setVgrow(modelEditorScroll, Priority.ALWAYS);
modelEditorOuter.setPrefWidth(300);
root.setRight(modelEditorOuter);
setStatus("Modell-Editor: " + relPath);
}
@@ -5895,6 +5980,150 @@ public class EditorApp extends Application {
// Initiale Gizmos pushen
pushAttachmentsToJme();
// ── Interaktivität ────────────────────────────────────────────────────
Label interactTitle = new Label("Interaktivität:");
interactTitle.setStyle("-fx-font-weight:bold; -fx-text-fill:#ccc;");
modelEditorInteractableCB = new ComboBox<>();
modelEditorInteractableCB.getItems().addAll(de.blight.common.model.InteractableType.values());
modelEditorInteractableCB.setValue(meta.interactableType());
modelEditorInteractableCB.setMaxWidth(Double.MAX_VALUE);
modelEditorInteractableCB.setConverter(new javafx.util.StringConverter<>() {
@Override public String toString(de.blight.common.model.InteractableType t) {
return t == null ? "" : t.getLabel();
}
@Override public de.blight.common.model.InteractableType fromString(String s) {
return de.blight.common.model.InteractableType.fromString(s);
}
});
// Ruhepunkt-Controls (nur sichtbar wenn BED oder BENCH gewählt)
// SharedInput mit bestehenden Meta-Werten initialisieren
input.modelInteractableOffsetX = meta.interactableOffsetX();
input.modelInteractableOffsetY = meta.interactableOffsetY();
input.modelInteractableOffsetZ = meta.interactableOffsetZ();
input.modelInteractableRotY = meta.interactableRotY();
input.modelInteractableOffsetChanged = true;
Label restPosHint = new Label("Klicke auf das Modell um den Ruhepunkt zu setzen:");
restPosHint.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;");
restPosHint.setWrapText(true);
Button setRestBtn = new Button("⊕ Im Modell klicken");
setRestBtn.setMaxWidth(Double.MAX_VALUE);
setRestBtn.setStyle("-fx-background-color:#1a5276; -fx-text-fill:#fff;");
setRestBtn.setOnAction(ev -> {
input.activeLayer = SharedInput.LAYER_MODEL_INTERACTABLE;
setStatus("Klicke auf das Modell um den Ruhepunkt zu setzen");
});
// Position-Spinner (X / Y / Z)
Label posLabel = new Label("Position (Modell-Koordinaten):");
posLabel.setStyle("-fx-text-fill:#aaa; -fx-font-size:10;");
modelEditorInteractableXSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetX() * 100.0) / 100.0, 0.05));
modelEditorInteractableXSpin.setEditable(true);
modelEditorInteractableXSpin.setPrefWidth(100);
modelEditorInteractableXSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetX = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
modelEditorInteractableYSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-1.0, 4.0,
Math.round(meta.interactableOffsetY() * 100.0) / 100.0, 0.05));
modelEditorInteractableYSpin.setEditable(true);
modelEditorInteractableYSpin.setPrefWidth(100);
modelEditorInteractableYSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetY = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
modelEditorInteractableZSpin = new Spinner<>(
new javafx.scene.control.SpinnerValueFactory.DoubleSpinnerValueFactory(-5.0, 5.0,
Math.round(meta.interactableOffsetZ() * 100.0) / 100.0, 0.05));
modelEditorInteractableZSpin.setEditable(true);
modelEditorInteractableZSpin.setPrefWidth(100);
modelEditorInteractableZSpin.valueProperty().addListener((obs, ov, nv) -> {
if (updatingInteractableSpinnersFromJme) return;
input.modelInteractableOffsetZ = nv.floatValue();
input.modelInteractableOffsetChanged = true;
});
javafx.scene.layout.GridPane posGrid = new javafx.scene.layout.GridPane();
posGrid.setHgap(4); posGrid.setVgap(2);
posGrid.add(new Label("X:"), 0, 0); posGrid.add(modelEditorInteractableXSpin, 1, 0);
posGrid.add(new Label("Y:"), 0, 1); posGrid.add(modelEditorInteractableYSpin, 1, 1);
posGrid.add(new Label("Z:"), 0, 2); posGrid.add(modelEditorInteractableZSpin, 1, 2);
posGrid.getChildren().stream()
.filter(n -> n instanceof Label)
.forEach(n -> ((Label) n).setStyle("-fx-text-fill:#aaa;"));
Label rotLabel = new Label("Blickrichtung (°):");
rotLabel.setStyle("-fx-text-fill:#aaa;");
double initDeg = Math.toDegrees(meta.interactableRotY());
Slider rotSlider = new Slider(0, 360, initDeg);
rotSlider.setShowTickMarks(true);
rotSlider.setMajorTickUnit(90);
rotSlider.setPrefWidth(180);
TextField rotField = new TextField(String.format("%.1f", initDeg));
rotField.setPrefWidth(60);
rotField.setStyle("-fx-background-color:#333; -fx-text-fill:#eee;");
boolean[] syncingRot = {false};
rotSlider.valueProperty().addListener((obs, ov, nv) -> {
if (syncingRot[0]) return;
syncingRot[0] = true;
rotField.setText(String.format("%.1f", nv.doubleValue()));
syncingRot[0] = false;
input.modelInteractableRotY = (float) Math.toRadians(nv.doubleValue());
input.modelInteractableOffsetChanged = true;
});
Runnable applyRotField = () -> {
try {
double deg = Double.parseDouble(rotField.getText().replace(',', '.'));
deg = ((deg % 360) + 360) % 360;
if (syncingRot[0]) return;
syncingRot[0] = true;
rotSlider.setValue(deg);
syncingRot[0] = false;
input.modelInteractableRotY = (float) Math.toRadians(deg);
input.modelInteractableOffsetChanged = true;
} catch (NumberFormatException ignored) {}
};
rotField.setOnAction(e -> applyRotField.run());
rotField.focusedProperty().addListener((obs, ov, nv) -> { if (!nv) applyRotField.run(); });
HBox rotRow = new HBox(6, rotSlider, rotField);
rotRow.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
javafx.scene.layout.VBox restPointBox = new javafx.scene.layout.VBox(4,
restPosHint, setRestBtn, posLabel, posGrid,
rotLabel, rotRow);
restPointBox.setStyle("-fx-padding: 4 0 0 0;");
boolean isBedOrBench = meta.interactableType() == de.blight.common.model.InteractableType.BED
|| meta.interactableType() == de.blight.common.model.InteractableType.BENCH;
restPointBox.setVisible(isBedOrBench);
restPointBox.setManaged(isBedOrBench);
modelEditorInteractableCB.valueProperty().addListener((obs, ov, nv) -> {
boolean show = nv == de.blight.common.model.InteractableType.BED
|| nv == de.blight.common.model.InteractableType.BENCH;
restPointBox.setVisible(show);
restPointBox.setManaged(show);
// Pfeil ein-/ausblenden — über SharedInput-Flag signalisieren
input.modelInteractableOffsetChanged = show;
});
// ── Buttons ───────────────────────────────────────────────────────────
Button saveBtn = new Button("💾 Speichern");
saveBtn.setMaxWidth(Double.MAX_VALUE);
@@ -5926,7 +6155,14 @@ public class EditorApp extends Application {
(float)(double) rndMaxSpin.getValue(),
modelEditorLod1Path, modelEditorLod2Path,
new java.util.ArrayList<>(modelEditorLights),
new java.util.ArrayList<>(modelEditorEmitters)));
new java.util.ArrayList<>(modelEditorEmitters),
modelEditorInteractableCB != null
? modelEditorInteractableCB.getValue()
: de.blight.common.model.InteractableType.NONE,
input.modelInteractableOffsetX,
input.modelInteractableOffsetY,
input.modelInteractableOffsetZ,
input.modelInteractableRotY));
placeBtn.setOnAction(e -> {
input.modelEditorCloseRequest = true;
@@ -5967,6 +6203,8 @@ public class EditorApp extends Application {
lightSectionLbl, modelEditorLightBox, addLightBtn,
emitterSectionLbl, modelEditorEmitterBox, addEmitterBtn,
new Separator(),
interactTitle, modelEditorInteractableCB, restPointBox,
new Separator(),
saveBtn, placeBtn, closeBtn
);
return panel;
@@ -5994,6 +6232,7 @@ public class EditorApp extends Application {
}
private void startImportFile(File file) {
archiveOriginal(file, "models");
String name = file.getName();
String baseName = name.replaceFirst("\\.[^.]+$", "");
Path destDir = ASSET_ROOT.resolve("Models").resolve("imported");
@@ -6415,12 +6654,17 @@ public class EditorApp extends Application {
float rndMin, float rndMax,
String lod1Path, String lod2Path,
java.util.List<de.blight.common.ModelMeta.AttachedLight> lights,
java.util.List<de.blight.common.ModelMeta.AttachedEmitter> emitters) {
java.util.List<de.blight.common.ModelMeta.AttachedEmitter> emitters,
de.blight.common.model.InteractableType interactableType,
float interactableOffsetX, float interactableOffsetY,
float interactableOffsetZ, float interactableRotY) {
de.blight.common.ModelMeta meta = new de.blight.common.ModelMeta(
name, category, tags, sx, sy, sz, uniform,
pivotY, placeY, solid, cast, receive, rndMin, rndMax,
lod1Path, lod2Path, 30f, 80f, 120f,
lights, emitters);
lights, emitters,
interactableType != null ? interactableType : de.blight.common.model.InteractableType.NONE,
interactableOffsetX, interactableOffsetY, interactableOffsetZ, interactableRotY);
if (absolutePath == null || !absolutePath.toFile().exists()) {
setStatus("Fehler: Modell-Datei nicht gefunden Meta nicht gespeichert");
@@ -6659,13 +6903,13 @@ public class EditorApp extends Application {
viewport.setPreserveRatio(false);
viewport.setFocusTraversable(true);
minimapCanvas = new javafx.scene.canvas.Canvas(164, 164);
minimapCanvas.setMouseTransparent(true);
StackPane.setAlignment(minimapCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
StackPane.setMargin(minimapCanvas, new Insets(0, 10, 10, 0));
drawMinimap(); // Initiales Zeichnen (leer)
compassCanvas = new javafx.scene.canvas.Canvas(100, 100);
compassCanvas.setMouseTransparent(true);
StackPane.setAlignment(compassCanvas, javafx.geometry.Pos.BOTTOM_RIGHT);
StackPane.setMargin(compassCanvas, new Insets(0, 10, 10, 0));
drawCompass();
StackPane pane = new StackPane(viewport, minimapCanvas);
StackPane pane = new StackPane(viewport, compassCanvas);
pane.setStyle("-fx-background-color: #1a1a2e;");
javafx.animation.PauseTransition resizeDebounce =
@@ -6846,6 +7090,11 @@ public class EditorApp extends Application {
}
case SharedInput.LAYER_VOXEL ->
input.voxelEditQueue.offer(new SharedInput.VoxelEdit((float) x, (float) y, action));
case SharedInput.LAYER_MODEL_INTERACTABLE -> {
if (action > 0)
input.modelInteractableClickQueue.offer(
new SharedInput.ModelInteractableClick((float) x, (float) y));
}
}
}
@@ -7006,61 +7255,72 @@ public class EditorApp extends Application {
gameConsoleArea.setScrollTop(Double.MAX_VALUE);
}
// ── Minimap ───────────────────────────────────────────────────────────────
// ── Kompass ───────────────────────────────────────────────────────────────
private static final float WORLD_HALF = 2048f; // Welt geht von -2048 bis +2048
private void drawCompass() {
if (compassCanvas == null) return;
final double S = compassCanvas.getWidth(); // 100
final double cx = S / 2.0;
final double cy = S / 2.0;
final double R = cx - 3; // Radius bis zum Rand
private void drawMinimap() {
if (minimapCanvas == null) return;
final double SIZE = minimapCanvas.getWidth(); // 164
final double INNER = SIZE - 4; // 160 innere Kartenfläche
final double OFFSET = 2;
javafx.scene.canvas.GraphicsContext gc = compassCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, S, S);
javafx.scene.canvas.GraphicsContext gc = minimapCanvas.getGraphicsContext2D();
gc.clearRect(0, 0, SIZE, SIZE);
// Hintergrund: dunkler Kreis
gc.setFill(javafx.scene.paint.Color.rgb(12, 12, 24, 0.82));
gc.fillOval(2, 2, S - 4, S - 4);
gc.setStroke(javafx.scene.paint.Color.rgb(100, 100, 160, 0.85));
gc.setLineWidth(1.5);
gc.strokeOval(2, 2, S - 4, S - 4);
// Hintergrund + Rahmen
gc.setFill(javafx.scene.paint.Color.rgb(10, 10, 20, 0.75));
gc.fillRoundRect(0, 0, SIZE, SIZE, 6, 6);
gc.setStroke(javafx.scene.paint.Color.rgb(120, 120, 180, 0.8));
gc.setLineWidth(1);
gc.strokeRoundRect(0.5, 0.5, SIZE - 1, SIZE - 1, 6, 6);
// Rotierende Rose: -camYaw so dass die aktuelle Blickrichtung oben erscheint
gc.save();
gc.translate(cx, cy);
gc.rotate(-input.camYaw);
// Hilfslinien (Weltmitte)
gc.setStroke(javafx.scene.paint.Color.rgb(80, 80, 110, 0.5));
gc.setLineWidth(0.5);
double mid = OFFSET + INNER / 2.0;
gc.strokeLine(mid, OFFSET, mid, OFFSET + INNER);
gc.strokeLine(OFFSET, mid, OFFSET + INNER, mid);
// Kameraposition (blauer Pfeil/Dreieck)
if (Float.isFinite(input.camX) && Float.isFinite(input.camZ)) {
double mx = worldToMap(input.camX, INNER, OFFSET);
double mz = worldToMap(input.camZ, INNER, OFFSET);
double yaw = Math.toRadians(input.camYaw); // Yaw: 0 = Norden, positiv = Uhrzeigersinn
// Tick-Striche an 8 Positionen (45°-Schritte)
for (int i = 0; i < 8; i++) {
double a = Math.toRadians(i * 45.0);
double len = (i % 2 == 0) ? 9 : 5;
gc.setStroke(javafx.scene.paint.Color.rgb(160, 160, 200, 0.65));
gc.setLineWidth(i % 2 == 0 ? 1.5 : 1.0);
gc.strokeLine(
Math.sin(a) * (R - len), -Math.cos(a) * (R - len),
Math.sin(a) * R, -Math.cos(a) * R);
}
// Himmelsrichtungen (N/E/S/W), Text durch Gegenrotation immer lesbar
String[] dirs = {"N", "E", "S", "W"};
double[] angles = {0.0, 90.0, 180.0, 270.0};
for (int i = 0; i < 4; i++) {
double a = Math.toRadians(angles[i]);
double tx = Math.sin(a) * (R - 16);
double ty = -Math.cos(a) * (R - 16);
gc.save();
gc.translate(mx, mz);
gc.rotate(Math.toDegrees(yaw));
gc.setFill(javafx.scene.paint.Color.rgb(80, 160, 255, 0.95));
gc.setStroke(javafx.scene.paint.Color.WHITE);
gc.setLineWidth(0.8);
// Kleines Dreieck zeigt Blickrichtung
double[] px = { 0, -4.5, 4.5 };
double[] pz = { -7, 5, 5 };
gc.fillPolygon(px, pz, 3);
gc.strokePolygon(px, pz, 3);
gc.translate(tx, ty);
gc.rotate(input.camYaw); // Gegenrotation zur Rose → Buchstabe immer aufrecht
gc.setFill(i == 0
? javafx.scene.paint.Color.rgb(255, 80, 80)
: javafx.scene.paint.Color.rgb(210, 210, 230));
gc.setFont(javafx.scene.text.Font.font(
"System", javafx.scene.text.FontWeight.BOLD, i == 0 ? 13 : 11));
gc.fillText(dirs[i], -4.5, 5.0);
gc.restore();
}
// Beschriftung
gc.setFill(javafx.scene.paint.Color.rgb(180, 180, 220, 0.7));
gc.setFont(javafx.scene.text.Font.font(8));
gc.fillText("N", mid - 3, OFFSET + 9);
}
gc.restore(); // Ende rotierende Rose
private static double worldToMap(float world, double innerSize, double offset) {
return offset + (world + WORLD_HALF) / (WORLD_HALF * 2) * innerSize;
// Fixer Richtungszeiger oben (gelbes Dreieck, zeigt immer die Blickrichtung)
gc.setFill(javafx.scene.paint.Color.rgb(255, 220, 60, 0.95));
gc.fillPolygon(
new double[]{cx, cx - 5, cx + 5},
new double[]{cy - R + 5, cy - R + 16, cy - R + 16},
3);
// Mittelpunkt
gc.setFill(javafx.scene.paint.Color.rgb(200, 200, 240, 0.85));
gc.fillOval(cx - 2.5, cy - 2.5, 5, 5);
}
private void saveCameraPrefs() {
@@ -7970,6 +8230,10 @@ public class EditorApp extends Application {
.forEach(animSetModelCombo.getItems()::add);
} catch (IOException ignored) {}
}
// Gespeicherten Modell-Pfad vorauswählen
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
animSetModelCombo.setValue(animSet.getPreviewModelPath());
}
Button loadModelBtn = new Button("Laden");
loadModelBtn.setMaxWidth(Double.MAX_VALUE);
loadModelBtn.setOnAction(e -> {
@@ -7977,9 +8241,18 @@ public class EditorApp extends Application {
if (path == null || path.isBlank()) return;
input.animPreviewLoadPath = path;
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
// Pfad im AnimSet merken und sofort speichern
animSet.setPreviewModelPath(path);
animSetDirty = true;
});
inner.getChildren().addAll(animSetModelCombo, loadModelBtn);
// Modell beim Öffnen automatisch laden, wenn Pfad bekannt
if (animSet.getPreviewModelPath() != null && !animSet.getPreviewModelPath().isBlank()) {
input.animPreviewLoadPath = animSet.getPreviewModelPath();
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade…");
}
// ── Clips im Set ─────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Clips im Set"), new Separator());
@@ -8045,14 +8318,15 @@ public class EditorApp extends Application {
ListView<String> list = new ListView<>();
list.getItems().addAll(notYetAdded);
list.getSelectionModel().setSelectionMode(javafx.scene.control.SelectionMode.MULTIPLE);
list.setPrefHeight(Math.min(notYetAdded.size() * 26 + 4, 320));
list.setPrefSize(460, 320);
list.setMinSize(460, 320);
list.getSelectionModel().selectFirst();
javafx.scene.control.Dialog<java.util.List<String>> dlg = new javafx.scene.control.Dialog<>();
dlg.setTitle("Animation(en) hinzufügen");
dlg.setHeaderText("Verfügbare Clips (noch nicht im Set) — Mehrfachauswahl möglich:");
dlg.getDialogPane().setContent(list);
dlg.getDialogPane().setPrefWidth(360);
dlg.getDialogPane().setPrefWidth(500);
javafx.scene.control.ButtonType ok = new javafx.scene.control.ButtonType("Hinzufügen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
dlg.getDialogPane().getButtonTypes().addAll(ok, javafx.scene.control.ButtonType.CANCEL);
@@ -8114,6 +8388,197 @@ public class EditorApp extends Application {
HBox.setHgrow(removeActionBtn, Priority.ALWAYS);
inner.getChildren().addAll(animSetActionListView, actionBtns);
// ── Bone-Anchoring ────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Bone-Anchoring"), new Separator());
Label anchorHint = new Label("Pro Aktion: Knochen angeben, der auf seiner Welt-Y fixiert bleibt (z. B. SIT_DOWN → foot.l). Hat Vorrang vor manuellem Sink.");
anchorHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
anchorHint.setWrapText(true);
animSetBonesLabel = new Label(animJointNames.isEmpty() ? "Kein Modell geladen" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#888;" : "#6a6;"));
animSetAnchorBoneListView = new ListView<>();
animSetAnchorBoneListView.setPrefHeight(110);
if (animSet.getAnchorBoneMap() != null) {
for (var e2 : animSet.getAnchorBoneMap().entrySet()) {
animSetAnchorBoneListView.getItems().add(e2.getKey() + "" + e2.getValue());
}
}
Button addAnchorBtn = new Button("+ Hinzufügen…");
Button removeAnchorBtn = new Button("- Entfernen");
addAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setMaxWidth(Double.MAX_VALUE);
removeAnchorBtn.setDisable(true);
animSetAnchorBoneListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeAnchorBtn.setDisable(nv == null));
addAnchorBtn.setOnAction(e -> {
// Pending joint names aus JME3-Thread abholen (falls Timer sie noch nicht konsumiert hat)
java.util.List<String> fresh = input.animPreviewJointNames.getAndSet(null);
if (fresh != null) {
animJointNames = new java.util.ArrayList<>(fresh);
if (animSetBonesLabel != null) {
animSetBonesLabel.setText(animJointNames.isEmpty() ? "Kein Armature gefunden" : animJointNames.size() + " Joints geladen");
animSetBonesLabel.setStyle("-fx-font-size: 10; -fx-text-fill: " + (animJointNames.isEmpty() ? "#c66;" : "#6a6;"));
}
}
ComboBox<de.blight.game.animation.AnimationAction> anchorActionCombo = new ComboBox<>();
anchorActionCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> acf =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
anchorActionCombo.setCellFactory(acf);
anchorActionCombo.setButtonCell(acf.call(null));
anchorActionCombo.setMaxWidth(Double.MAX_VALUE);
anchorActionCombo.getSelectionModel().selectFirst();
// Joint-Auswahl: ComboBox mit geladenen Namen, editierbar als Fallback
ComboBox<String> boneCombo = new ComboBox<>();
boneCombo.setEditable(true);
boneCombo.setMaxWidth(Double.MAX_VALUE);
if (animJointNames.isEmpty()) {
boneCombo.setPromptText("Joint-Name (erst Modell laden)");
} else {
boneCombo.getItems().addAll(animJointNames);
boneCombo.setPromptText("Joint auswählen…");
}
javafx.scene.layout.GridPane anchorGrid = new javafx.scene.layout.GridPane();
anchorGrid.setHgap(8); anchorGrid.setVgap(6);
anchorGrid.add(new Label("Aktion:"), 0, 0); anchorGrid.add(anchorActionCombo, 1, 0);
anchorGrid.add(new Label("Joint-Name:"), 0, 1); anchorGrid.add(boneCombo, 1, 1);
javafx.scene.layout.ColumnConstraints anchorCc = new javafx.scene.layout.ColumnConstraints();
anchorCc.setHgrow(Priority.ALWAYS);
anchorGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), anchorCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> anchorDlg = new javafx.scene.control.Dialog<>();
anchorDlg.setTitle("Bone-Anchoring konfigurieren");
javafx.scene.control.ButtonType okAnchor = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
anchorDlg.getDialogPane().getButtonTypes().addAll(okAnchor, javafx.scene.control.ButtonType.CANCEL);
anchorDlg.getDialogPane().setContent(anchorGrid);
anchorDlg.showAndWait().ifPresent(bt -> {
if (bt != okAnchor) {
return;
}
var selAction = anchorActionCombo.getValue();
String bone = boneCombo.getEditor().getText();
if (selAction == null || bone == null || bone.isBlank()) {
return;
}
String newEntry = selAction.name() + "" + bone.trim();
animSetAnchorBoneListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetAnchorBoneListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeAnchorBtn.setOnAction(e -> {
String sel = animSetAnchorBoneListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetAnchorBoneListView.getItems().remove(sel);
animSetDirty = true;
}
});
HBox anchorBtns = new HBox(6, addAnchorBtn, removeAnchorBtn);
HBox.setHgrow(addAnchorBtn, Priority.ALWAYS);
HBox.setHgrow(removeAnchorBtn, Priority.ALWAYS);
inner.getChildren().addAll(anchorHint, animSetBonesLabel, animSetAnchorBoneListView, anchorBtns);
// ── Sink-Konfiguration (Fallback) ─────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Manueller Sink-Fallback"), new Separator());
Label sinkHint = new Label("Root-Motion-Ersatz: Körper senkt/hebt sich während der Animation.\nNegativ = nach unten (Setzen), Positiv = nach oben.");
sinkHint.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
sinkHint.setWrapText(true);
animSetSinkListView = new ListView<>();
animSetSinkListView.setPrefHeight(120);
if (animSet.getSinkMap() != null) {
for (var e2 : animSet.getSinkMap().entrySet()) {
animSetSinkListView.getItems().add(e2.getKey() + "" + e2.getValue());
}
}
Button addSinkBtn = new Button("+ Setzen…");
Button removeSinkBtn = new Button("- Entfernen");
addSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setMaxWidth(Double.MAX_VALUE);
removeSinkBtn.setDisable(true);
animSetSinkListView.getSelectionModel().selectedItemProperty()
.addListener((obs, ov, nv) -> removeSinkBtn.setDisable(nv == null));
addSinkBtn.setOnAction(e -> {
ComboBox<de.blight.game.animation.AnimationAction> actionSinkCombo = new ComboBox<>();
actionSinkCombo.getItems().addAll(de.blight.game.animation.AnimationAction.values());
javafx.util.Callback<javafx.scene.control.ListView<de.blight.game.animation.AnimationAction>,
javafx.scene.control.ListCell<de.blight.game.animation.AnimationAction>> cf2 =
lv -> new javafx.scene.control.ListCell<>() {
@Override protected void updateItem(de.blight.game.animation.AnimationAction it, boolean empty) {
super.updateItem(it, empty);
setText(empty || it == null ? null : it.displayName() + " (" + it.name() + ")");
}
};
actionSinkCombo.setCellFactory(cf2);
actionSinkCombo.setButtonCell(cf2.call(null));
actionSinkCombo.setMaxWidth(Double.MAX_VALUE);
actionSinkCombo.getSelectionModel().selectFirst();
Spinner<Double> sinkSpinner = new Spinner<>(-3.0, 3.0, 0.0, 0.05);
sinkSpinner.setEditable(true);
sinkSpinner.setMaxWidth(Double.MAX_VALUE);
javafx.scene.layout.GridPane sinkGrid = new javafx.scene.layout.GridPane();
sinkGrid.setHgap(8); sinkGrid.setVgap(6);
sinkGrid.add(new Label("Aktion:"), 0, 0); sinkGrid.add(actionSinkCombo, 1, 0);
sinkGrid.add(new Label("Versatz (m):"), 0, 1); sinkGrid.add(sinkSpinner, 1, 1);
javafx.scene.layout.ColumnConstraints sinkCc = new javafx.scene.layout.ColumnConstraints();
sinkCc.setHgrow(Priority.ALWAYS);
sinkGrid.getColumnConstraints().addAll(new javafx.scene.layout.ColumnConstraints(), sinkCc);
javafx.scene.control.Dialog<javafx.scene.control.ButtonType> sinkDlg = new javafx.scene.control.Dialog<>();
sinkDlg.setTitle("Sink-Wert setzen");
javafx.scene.control.ButtonType okSink = new javafx.scene.control.ButtonType("Setzen",
javafx.scene.control.ButtonBar.ButtonData.OK_DONE);
sinkDlg.getDialogPane().getButtonTypes().addAll(okSink, javafx.scene.control.ButtonType.CANCEL);
sinkDlg.getDialogPane().setContent(sinkGrid);
sinkDlg.showAndWait().ifPresent(bt -> {
if (bt != okSink) {
return;
}
var selAction = actionSinkCombo.getValue();
if (selAction == null) {
return;
}
double val = sinkSpinner.getValue();
String newEntry = selAction.name() + "" + val;
// Bestehenden Eintrag für diese Aktion ersetzen
animSetSinkListView.getItems().removeIf(it -> it.startsWith(selAction.name() + ""));
animSetSinkListView.getItems().add(newEntry);
animSetDirty = true;
});
});
removeSinkBtn.setOnAction(e -> {
String sel = animSetSinkListView.getSelectionModel().getSelectedItem();
if (sel != null) {
animSetSinkListView.getItems().remove(sel);
animSetDirty = true;
}
});
HBox sinkBtns = new HBox(6, addSinkBtn, removeSinkBtn);
HBox.setHgrow(addSinkBtn, Priority.ALWAYS);
HBox.setHgrow(removeSinkBtn, Priority.ALWAYS);
inner.getChildren().addAll(sinkHint, animSetSinkListView, sinkBtns);
// ── Vorschau ─────────────────────────────────────────────────────────
inner.getChildren().addAll(new Separator(), sectionTitle("Vorschau"), new Separator());
@@ -8182,13 +8647,21 @@ public class EditorApp extends Application {
return;
}
animSetPendingPlayClip = clip;
// Clip zur aktuell geladenen Figur hinzufügen (nicht als Modell laden).
// Nach Abschluss setzt AnimPreviewState animPreviewClips, das den
// animSetPendingPlayClip-Trigger auslöst und den Clip abspielt.
input.animPreviewAddAnimPath = "animations/clips/" + clip + ".j3o";
input.animImportQueue.offer(findAnimClipPath(clip));
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Lade Clip " + clip + "");
}
private String findAnimClipPath(String clipName) {
Path animDir = ASSET_ROOT.resolve("animations");
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (java.nio.file.Files.exists(animDir.resolve("clips").resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
if (java.nio.file.Files.exists(animDir.resolve(clipName + ext)))
return "animations/" + clipName + ext;
}
return "animations/clips/" + clipName + ".j3o";
}
private void showAddActionToSetDialog() {
if (animSetClipListView == null || animSetClipListView.getItems().isEmpty()) {
setStatus("Keine Clips im Set — erst Clips hinzufügen.");
@@ -8254,16 +8727,47 @@ public class EditorApp extends Application {
}
private void saveCurrentAnimSet(String setName, Path setDir) {
if (animSetClipListView == null) return;
if (animSetClipListView == null) {
return;
}
de.blight.game.animation.AnimSet animSet = new de.blight.game.animation.AnimSet();
animSet.setClips(new java.util.ArrayList<>(animSetClipListView.getItems()));
java.util.Map<String, String> actionMap = new java.util.LinkedHashMap<>();
if (animSetActionListView != null)
if (animSetActionListView != null) {
for (String it : animSetActionListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) actionMap.put(parts[0], parts[1]);
if (parts.length == 2) {
actionMap.put(parts[0], parts[1]);
}
}
}
animSet.setActionMap(actionMap);
java.util.Map<String, Float> sinkMap = new java.util.LinkedHashMap<>();
if (animSetSinkListView != null) {
for (String it : animSetSinkListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
try {
sinkMap.put(parts[0], Float.parseFloat(parts[1]));
} catch (NumberFormatException ignored) {}
}
}
}
animSet.setSinkMap(sinkMap);
java.util.Map<String, String> anchorBoneMap = new java.util.LinkedHashMap<>();
if (animSetAnchorBoneListView != null) {
for (String it : animSetAnchorBoneListView.getItems()) {
String[] parts = it.split("", 2);
if (parts.length == 2) {
anchorBoneMap.put(parts[0], parts[1]);
}
}
}
animSet.setAnchorBoneMap(anchorBoneMap);
// Vorschau-Modell-Pfad beibehalten
if (animSetModelCombo != null && animSetModelCombo.getValue() != null && !animSetModelCombo.getValue().isBlank()) {
animSet.setPreviewModelPath(animSetModelCombo.getValue());
}
try {
animSet.save(setDir, setName);
setStatus("AnimSet gespeichert: " + setName + ".animset.json");
@@ -8443,7 +8947,7 @@ public class EditorApp extends Application {
animPreviewStatusLabel.setText("Bitte eine Animation auswählen");
return;
}
input.animPreviewAddAnimPath = animPath;
input.animImportQueue.offer(animPath);
if (animPreviewStatusLabel != null) animPreviewStatusLabel.setText("Füge Clips hinzu…");
});
inner.getChildren().addAll(animHint, importAnimBtn, addAnimCombo, addAnimBtn);
@@ -8492,6 +8996,23 @@ public class EditorApp extends Application {
if (current != null && combo.getItems().contains(current)) combo.setValue(current);
}
/**
* Kopiert die Original-Quelldatei nach {@code <projekt-root>/assets/imported/<assetType>/}.
* Dient als Archiv vor jeder Konvertierung; Fehler werden nur geloggt.
*/
private void archiveOriginal(File source, String assetType) {
Path dest = ProjectRoot.PATH.resolve("assets").resolve("imported").resolve(assetType);
try {
Files.createDirectories(dest);
Files.copy(source.toPath(), dest.resolve(source.getName()),
java.nio.file.StandardCopyOption.REPLACE_EXISTING);
log.info("[Import] Archiviert: {}/{}", assetType, source.getName());
} catch (IOException ex) {
log.warn("[Import] Archivierung fehlgeschlagen ({}/{}): {}",
assetType, source.getName(), ex.getMessage());
}
}
private void handleAnimationImport(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Animation importieren (GLB/GLTF)");
@@ -8500,19 +9021,12 @@ public class EditorApp extends Application {
var files = fc.showOpenMultipleDialog(owner);
if (files == null) return;
for (File file : files) {
try {
Path destDir = ASSET_ROOT.resolve("animations").resolve("clips");
Files.createDirectories(destDir);
Path destFile = destDir.resolve(file.getName());
Files.copy(file.toPath(), destFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
setStatus("Animation importiert: " + file.getName());
} catch (IOException ex) {
setStatus("Fehler beim Animations-Import: " + ex.getMessage());
}
archiveOriginal(file, "animations");
// Absoluten Pfad übergeben kein Kopieren nötig.
// addAnimation() lädt direkt vom Ursprungsort und speichert nur J3O nach clips/.
input.animImportQueue.offer(file.getAbsolutePath());
setStatus("Importiere: " + file.getName() + "");
}
// Sofort im JavaFX-Thread aktualisieren keine Konvertierung nötig
refreshCategoryNode(animationsNode, shouldPreserveExpansion());
refreshAddAnimCombo(addAnimComboField);
}
private void reimportModelForPreview(javafx.stage.Window owner) {
@@ -8522,6 +9036,7 @@ public class EditorApp extends Application {
new FileChooser.ExtensionFilter("3D-Modelle (GLTF, GLB)", "*.gltf", "*.glb"));
File file = fc.showOpenDialog(owner);
if (file == null) return;
archiveOriginal(file, "models");
String selectedJ3o = animPreviewModelCombo != null ? animPreviewModelCombo.getValue() : null;
Path destDir;

View File

@@ -35,7 +35,10 @@ import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
import de.blight.editor.state.TreeGeneratorState;
import de.blight.editor.state.VoxelEditorState;
import de.blight.editor.state.SculptedMeshEditorState;
import de.blight.editor.state.ModelImportState;
import de.blight.editor.state.PathNetworkEditorState;
import de.blight.editor.state.RoutineMapState;
import de.blight.game.console.JmeConsole;
import de.blight.game.state.DayNightState;
import javafx.scene.image.WritableImage;
@@ -192,10 +195,13 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new LocationZoneState(input));
stateManager.attach(new RiverEditorState(input));
stateManager.attach(new PlayToolState(input));
stateManager.attach(new RoutineMapState(input));
stateManager.attach(new PathNetworkEditorState(input));
stateManager.attach(new AnimPreviewState(input));
stateManager.attach(new ModelEditorState(input));
stateManager.attach(new ItemPlacementState(input));
stateManager.attach(new VoxelEditorState(input));
stateManager.attach(new SculptedMeshEditorState(input));
stateManager.attach(new ModelImportState(input));
// NaN-sichere Comparatoren einsetzen (verhindern den TimSort-Crash bei kaputten Bounds)

View File

@@ -5,8 +5,10 @@ import de.blight.editor.tool.GrassTool;
import de.blight.editor.tool.GrassVertexTool;
import de.blight.editor.tool.HeightTool;
import de.blight.editor.tool.HoleTool;
import de.blight.editor.tool.StoneTool;
import de.blight.editor.tool.TextureTool;
import de.blight.editor.tool.UpperHeightTool;
import de.blight.editor.tool.SculptMeshTool;
import de.blight.editor.tool.VoxelTool;
import de.blight.editor.tree.PalmOptions;
import de.blight.editor.tree.TreeParams;
@@ -26,8 +28,10 @@ public class SharedInput {
public final GrassVertexTool grassVertexTool = new GrassVertexTool();
public final TextureTool textureTool = new TextureTool();
public final HoleTool holeTool = new HoleTool();
public final VoxelTool voxelTool = new VoxelTool();
public volatile EditorTool activeTool = heightTool;
public final VoxelTool voxelTool = new VoxelTool();
public final StoneTool stoneTool = new StoneTool();
public final SculptMeshTool sculptTool = new SculptMeshTool();
public volatile EditorTool activeTool = heightTool;
// ── Initialisierungs-Status ───────────────────────────────────────────────
public volatile boolean jmeReady = false;
@@ -273,6 +277,11 @@ public class SharedInput {
/** JavaFX → JME: AnimationLibrary-Clip-Key für das selektierte Objekt. null = kein Auftrag. */
public volatile String pendingAnimClip = null;
/** JavaFX → JME: Interactable-Typ des selektierten Objekts ("CRAFTING_TABLE"/"BED"/""). null = kein Auftrag. */
public volatile String pendingInteractableType = null;
/** JavaFX → JME: Interactable-ID des selektierten Objekts. null = kein Auftrag. */
public volatile String pendingInteractableId = null;
// Objekt-Eigenschaften (Position, Rotation, Textur) von JavaFX setzen
public record ObjectPropertyChange(
float x, float y, float z,
@@ -541,7 +550,7 @@ public class SharedInput {
/** 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;
public final ConcurrentLinkedQueue<String> animImportQueue = new ConcurrentLinkedQueue<>();
/** 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. */
@@ -553,9 +562,14 @@ public class SharedInput {
public volatile boolean animPreviewLoop = true;
/** JME3 → JavaFX: Status-Meldung nach Laden / Fehler. */
public volatile String animPreviewStatus = null;
/** JME3 → JavaFX: Signalisiert, dass ein Import abgeschlossen wurde → Combo neu laden. */
public volatile boolean animImportCompleted = false;
/** 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: Joint-Namen des geladenen Armatures (für Bone-Anchoring-Auswahl). */
public final java.util.concurrent.atomic.AtomicReference<java.util.List<String>>
animPreviewJointNames = new java.util.concurrent.atomic.AtomicReference<>();
/**
* JME3 → JavaFX: Relativer Asset-Pfad des gerade geladenen Modells.
@@ -656,15 +670,29 @@ public class SharedInput {
public volatile String modelEditorLod2Path = "";
public volatile boolean modelEditorLodChanged = false;
/** JFX → JME: Richtung der Hauptlichtquelle in der Vorschau.
* Azimut 0360° (Kompassrichtung), Elevation 090° (Höhe über Horizont). */
public volatile float modelEditorLightAzimuth = 51f;
public volatile float modelEditorLightElevation = 57f;
public volatile boolean modelEditorLightChanged = false;
// ── Voxel-Werkzeug ────────────────────────────────────────────────────────
/** activeLayer==16 → Voxel-Klippen/Höhlen bearbeiten */
public static final int LAYER_VOXEL = 16;
/** Klick/Drag im Viewport im Voxel-Modus. */
// Klick/Drag im Viewport im Voxel-Modus.
/** action +1 = Linksklick (erhöhen/hinzufügen), -1 = Rechtsklick (senken/entfernen). */
public record VoxelEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<VoxelEdit> voxelEditQueue = new ConcurrentLinkedQueue<>();
/** JFX → JME: Aktions-Grenzen für Undo-Snapshots. */
public volatile boolean voxelActionStarted = false;
public volatile boolean voxelActionFinished = false;
/** JFX → JME: Undo/Redo-Anfragen (Ctrl+Z / Ctrl+Shift+Z). */
public volatile boolean voxelUndoRequested = false;
public volatile boolean voxelRedoRequested = false;
/** JFX → JME: alle Voxel-Chunks als geglättete J3O-Meshes backen. */
public volatile boolean bakeVoxelsRequested = false;
/** JME → JFX: Anzahl bereits gebackener Chunks (0 = nicht gestartet). */
@@ -673,6 +701,8 @@ public class SharedInput {
public volatile int bakeTotal = 0;
/** JME → JFX: Status-Meldung nach Abschluss des Backens. */
public volatile String bakeStatusMsg = null;
/** JME → JFX: Aktuell abgeschlossene Blur-Iteration (0-7). */
public volatile int blurIterDone = 0;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
public volatile int voxelFlatSlot = -1;
@@ -683,6 +713,9 @@ public class SharedInput {
/** JFX setzt true wenn Voxel-Texturen geändert wurden; JME liest + resettet. */
public volatile boolean voxelTexturesChanged = false;
/** Wenn true, werden beim Aktivieren des Voxel-Layers alle anderen Objekte als Wireframe gerendert. */
public volatile boolean voxelWireframeEnabled = true;
// ── Item-Platzierung ──────────────────────────────────────────────────────
/** activeLayer==21 → Item-Pickup auf die Karte platzieren */
public static final int LAYER_ITEMS = 21;
@@ -720,6 +753,22 @@ public class SharedInput {
/** JME → JFX: Status-Meldung nach LOD-Generierung. */
public volatile String modelLodGenStatus = null;
// ── Steine ────────────────────────────────────────────────────────────────
/** activeLayer==23 → Steine setzen/entfernen */
public static final int LAYER_STONE = 23;
public record StoneEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<StoneEdit> stoneEditQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Rückmeldung nach einem Stein-Operation. */
public volatile String stoneStatusMsg = null;
/** JFX → JME: Reload der Materialien auslösen. */
public volatile boolean stoneTexturesChanged = false;
/** JME → JME: Terrain-Edits, die Stein-Höhen neu berechnen müssen. float[3] = {wx, wz, radius}. */
public final ConcurrentLinkedQueue<float[]> terrainEditedAreas = new ConcurrentLinkedQueue<>();
/** JFX → JME: LOD-Reduktions-Algorithmus. "blight" = Dihedral Edge Collapse, "jme" = JME3 Progressive Mesh. */
public volatile String modelLodAlgorithm = "blight";
@@ -731,4 +780,124 @@ public class SharedInput {
public volatile String modelImportExportName = null;
/** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */
public volatile String modelImportExportStatus = null;
// ── Mesh-Sculpting ───────────────────────────────────────────────────────
/** activeLayer==24 → gebackene Voxel-Meshes direkt sculpten */
public static final int LAYER_SCULPT = 24;
// ── Tagesablauf-Editor ────────────────────────────────────────────────────
/** activeLayer==25 → Tagesablauf-Karte (nur Kamera + Punktauswahl) */
public static final int LAYER_ROUTINE_EDITOR = 25;
/**
* JFX → JME: Punkt auf dem Terrain abgreifen.
* Wert: screenX|screenY. JME liest via Queue, setzt routinePickedPoint.
*/
public record RoutinePointClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<RoutinePointClick> routinePointClickQueue =
new ConcurrentLinkedQueue<>();
/**
* JME → JFX: Welt-Koordinaten des zuletzt geklickten Terrain-Punktes.
* Format: "x|y|z". null = noch kein Ergebnis.
*/
public volatile String routinePickedPoint = null;
public volatile boolean routinePickedChanged = false;
// ── Wegnetz-Editor ────────────────────────────────────────────────────────
/** activeLayer==26 → Wegnetz bearbeiten */
public static final int LAYER_PATH_NETWORK = 26;
/**
* JFX → JME: Klick im Wegnetz-Modus.
* rightClick=true → Löschen, false → Platzieren/Verbinden.
*/
public record PathNetworkClick(float screenX, float screenY, boolean rightClick) {}
public final ConcurrentLinkedQueue<PathNetworkClick> pathNetworkClickQueue =
new ConcurrentLinkedQueue<>();
/** JME → JFX: Wegnetz wurde verändert, UI-Refresh nötig. */
public volatile boolean pathNetworkChanged = false;
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/** activeLayer==27 → Liegefläche eines Bettes platzieren (Terrain-Klick → Mittelpunkt). */
public static final int LAYER_BED_LIEGE = 27;
/** JFX → JME: Klick im Liegeflächen-Platzierungsmodus. */
public record BedLiegeClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<BedLiegeClick> bedLiegeClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Terrain-Punkt, auf den geklickt wurde. Format "x|y|z". null = noch kein Ergebnis. */
public volatile String bedLiegePickResult = null;
public volatile boolean bedLiegePickChanged = false;
/** UUID des Bettes, für das gerade die Liegefläche gesetzt wird. */
public volatile String bedLiegeTargetId = null;
/** JFX → JME: Rotationsänderung der Liegefläche in Radiant. null = kein Auftrag. */
public volatile Float pendingBedLiegeRotY = null;
// ── Bank-Sitzfläche ───────────────────────────────────────────────────────
/** activeLayer==28 → Sitzfläche einer Bank platzieren (Terrain-Klick → Mittelpunkt). */
public static final int LAYER_BENCH_SITZ = 28;
/** JFX → JME: Klick im Sitzflächen-Platzierungsmodus. */
public record BenchSitzClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<BenchSitzClick> benchSitzClickQueue = new ConcurrentLinkedQueue<>();
/** JME → JFX: Terrain-Punkt, auf den geklickt wurde. Format "x|y|z". null = noch kein Ergebnis. */
public volatile String benchSitzPickResult = null;
public volatile boolean benchSitzPickChanged = false;
/** UUID der Bank, für die gerade die Sitzfläche gesetzt wird. */
public volatile String benchSitzTargetId = null;
/** JFX → JME: Rotationsänderung der Sitzfläche in Radiant. null = kein Auftrag. */
public volatile Float pendingBenchSitzRotY = null;
// ── Modell-Editor Interactable-Ruhepunkt ──────────────────────────────────
/** activeLayer==29 → Ruhepunkt am Modell platzieren (Klick → lokaler Offset). */
public static final int LAYER_MODEL_INTERACTABLE = 29;
public record ModelInteractableClick(float screenX, float screenY) {}
public final ConcurrentLinkedQueue<ModelInteractableClick> modelInteractableClickQueue =
new ConcurrentLinkedQueue<>();
/** JME → JFX: aktueller lokaler Offset des Ruhepunkts (Modell-Koordinaten). */
public volatile float modelInteractableOffsetX = 0f;
public volatile float modelInteractableOffsetY = 0.5f;
public volatile float modelInteractableOffsetZ = 0f;
public volatile float modelInteractableRotY = 0f;
public volatile boolean modelInteractableOffsetChanged = false;
/** Gesetzt vom JME-Thread nach Raycast-Klick, damit JFX-Spinner aktualisiert werden. */
public volatile boolean modelInteractablePosSetFromJme = false;
/** Klick/Drag im Viewport im Sculpt-Modus. action 0=links, 1=rechts. */
public record SculptEdit(float screenX, float screenY, int action) {}
public final ConcurrentLinkedQueue<SculptEdit> sculptEditQueue = new ConcurrentLinkedQueue<>();
/** Aktionsgrenzen für Undo-Snapshots. */
public volatile boolean sculptActionStarted = false;
public volatile boolean sculptActionFinished = false;
/** Undo/Redo-Anfragen (Ctrl+Z / Ctrl+Shift+Z). */
public volatile boolean sculptUndoRequested = false;
public volatile boolean sculptRedoRequested = false;
// ── Sculpt-Selektion und -Transform ─────────────────────────────────────
/** -1L = kein Element ausgewählt. JME3 schreibt, JavaFX liest. */
public volatile long selectedSculptKey = -1L;
/** Anzeige-Label für das ausgewählte Element. JME3 schreibt, JavaFX liest. */
public volatile String selectedSculptLabel = null;
/** Verschiebung in Welteinheiten. JavaFX schreibt, JME3 liest+löscht. */
public volatile float sculptTranslateX = 0f;
public volatile float sculptTranslateY = 0f;
public volatile float sculptTranslateZ = 0f;
public volatile boolean sculptApplyTranslate = false;
/** Rotation um Y-Achse in Grad. JavaFX schreibt, JME3 liest+löscht. */
public volatile float sculptRotateDeg = 45f;
public volatile boolean sculptApplyRotate = false;
/** Ausgewähltes Element löschen. JavaFX schreibt, JME3 liest+löscht. */
public volatile boolean sculptDeleteSelected = false;
/** VoxelEditorState setzt true nach Bake-Abschluss → SculptedMeshEditorState scannt neue .j3o-Dateien. */
public volatile boolean sculptRescanNeeded = false;
}

View File

@@ -24,6 +24,8 @@ public class SceneObject extends PlacedObject {
public float lod1Distance = 30f;
public float lod2Distance = 80f;
public float cullDistance = 120f;
public String interactableType = "";
public String interactableId = "";
public SceneObject(String modelPath, float worldX, float worldZ, float groundY,
boolean solid) {

View File

@@ -148,9 +148,8 @@ public class AnimPreviewState extends BaseAppState {
else playClip(playClip);
}
String addAnimPath = input.animPreviewAddAnimPath;
String addAnimPath = input.animImportQueue.poll();
if (addAnimPath != null) {
input.animPreviewAddAnimPath = null;
addAnimation(addAnimPath);
}
@@ -276,6 +275,22 @@ public class AnimPreviewState extends BaseAppState {
collectClips(model, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewLoadedPath.set(assetPath);
// Joint-Namen aus dem Armature sammeln und melden
SkinningControl sc = findControl(model, SkinningControl.class);
if (sc != null && sc.getArmature() != null) {
com.jme3.anim.Armature arm = sc.getArmature();
List<String> joints = new ArrayList<>();
for (int ji = 0; ji < arm.getJointCount(); ji++) {
joints.add(arm.getJoint(ji).getName());
}
Collections.sort(joints);
LOG.info("[AnimPreview] Armature: {} joints gefunden", joints.size());
input.animPreviewJointNames.set(Collections.unmodifiableList(joints));
} else {
LOG.warn("[AnimPreview] Kein SkinningControl/Armature gefunden in: {}", assetPath);
input.animPreviewJointNames.set(List.of());
}
if (clips.isEmpty()) {
if (!hasSkeleton(model)) {
input.animPreviewStatus =
@@ -289,8 +304,10 @@ public class AnimPreviewState extends BaseAppState {
input.animPreviewStatus = "Geladen: " + assetPath + " (" + clips.size() + " Clips)";
}
} catch (Exception e) {
LOG.error("[AnimPreview] Ladefehler: {}", assetPath, e);
input.animPreviewStatus = "Ladefehler: " + e.getMessage();
input.animPreviewClips.set(List.of());
input.animPreviewJointNames.set(List.of());
}
}
@@ -397,13 +414,10 @@ public class AnimPreviewState extends BaseAppState {
// ── Animation hinzufügen → direkt in Clip-Bibliothek speichern ───────────
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) {
// Kein Modell geladen → kein Retargeting, aber Clip trotzdem als J3O speichern
AnimComposer targetAC = currentModel != null ? findControl(currentModel, AnimComposer.class) : null;
SkinningControl targetSC = currentModel != null ? findControl(currentModel, SkinningControl.class) : null;
if (currentModel != null && targetAC == null) {
input.animPreviewStatus = "Fehler: Modell hat keinen AnimComposer";
return;
}
@@ -428,14 +442,14 @@ public class AnimPreviewState extends BaseAppState {
com.jme3.anim.Armature dstArm = targetSC != null ? targetSC.getArmature() : null;
if (srcArm != null) {
LOG.info("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
for (var j : srcArm.getJointList()) LOG.info(" src: {}", j.getName());
LOG.trace("[Retarget] Quell-Knochen ({}):", srcArm.getJointCount());
for (var j : srcArm.getJointList()) LOG.trace(" src: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl in Quelle!");
}
if (dstArm != null) {
LOG.info("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
for (var j : dstArm.getJointList()) LOG.info(" dst: {}", j.getName());
LOG.trace("[Retarget] Ziel-Knochen ({}):", dstArm.getJointCount());
for (var j : dstArm.getJointList()) LOG.trace(" dst: {}", j.getName());
} else {
LOG.warn("[Retarget] Keine SkinningControl im Modell!");
}
@@ -443,7 +457,7 @@ public class AnimPreviewState extends BaseAppState {
boolean retarget = srcArm != null && dstArm != null && srcArm != dstArm;
if (retarget) {
var mapping = de.blight.game.animation.BoneNameMapping.buildMapping(srcArm, dstArm);
LOG.info("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping);
LOG.trace("[Retarget] Mapping ({} Treffer): {}", mapping.size(), mapping);
}
java.util.Set<String> srcNames = new java.util.HashSet<>();
@@ -452,6 +466,15 @@ public class AnimPreviewState extends BaseAppState {
java.nio.file.Path clipsDir = ASSET_ROOT.resolve("animations").resolve("clips");
java.nio.file.Files.createDirectories(clipsDir);
// Blender exportiert GLB-Dateien oft mit internem Namen "Action" statt dem Dateinamen.
// Bei einer GLB/GLTF-Datei mit genau einem Clip: Dateinamen als Clip-Namen verwenden.
boolean isSingleClipGlb = animAssetPath.matches(".*\\.(glb|gltf)$")
&& sourceAC.getAnimClips().size() == 1;
String fileBaseName = isSingleClipGlb
? java.nio.file.Paths.get(animAssetPath).getFileName().toString()
.replaceFirst("\\.(glb|gltf)$", "")
: null;
int saved = 0;
for (AnimClip clip : sourceAC.getAnimClips()) {
String name = clip.getName();
@@ -467,19 +490,38 @@ public class AnimPreviewState extends BaseAppState {
: clip;
if (result == null) continue;
String saveName = (fileBaseName != null) ? fileBaseName : name;
AnimClip toSave = result;
if (!saveName.equals(result.getName())) {
toSave = new AnimClip(saveName);
toSave.setTracks(result.getTracks());
LOG.info("[AnimPreview] Clip '{}' als '{}' gespeichert (Dateiname-Alias)", name, saveName);
}
// Direkt in die Clip-Bibliothek speichern das Modell wird nicht modifiziert
saveClipToFile(result, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(name + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden
targetAC.addAnimClip(result);
saveClipToFile(toSave, dstArm != null ? dstArm : srcArm,
clipsDir.resolve(saveName + ".j3o"));
// Für den aktuellen Preview-Session auch auf das Modell anwenden (wenn geladen)
if (targetAC != null) targetAC.addAnimClip(toSave);
saved++;
}
// Temporäre GLB aus clips/ löschen (nur wenn sie dort drin liegt nicht externe Dateien)
if (saved > 0 && animAssetPath.matches(".*\\.(glb|gltf)$")
&& !Path.of(animAssetPath).isAbsolute()) {
Path srcGlb = ASSET_ROOT.resolve(animAssetPath.replace('/', java.io.File.separatorChar));
try {
java.nio.file.Files.deleteIfExists(srcGlb);
LOG.info("[AnimPreview] Temporäre GLB entfernt: {}", animAssetPath);
} catch (Exception ignored) {}
}
List<String> clips = new ArrayList<>();
collectClips(currentModel, clips);
if (currentModel != null) collectClips(currentModel, clips);
input.animPreviewClips.set(Collections.unmodifiableList(clips));
input.animPreviewStatus = saved + " Clip(s) in animations/clips/ gespeichert"
+ (retarget ? " (retargeted)" : " (direkt)");
if (saved > 0) input.animImportCompleted = true;
} catch (Exception e) {
LOG.error("[AnimPreview] Fehler beim Importieren von {}", animAssetPath, e);
input.animPreviewStatus = "Fehler beim Hinzufügen: " + e.getMessage();
}
}
@@ -576,7 +618,7 @@ public class AnimPreviewState extends BaseAppState {
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);
BitmapText label = new BitmapText(font);
label.setSize(0.18f);
label.setColor(color);
label.setText(text);
@@ -625,8 +667,23 @@ public class AnimPreviewState extends BaseAppState {
}
}
/** Lädt eine j3o-Datei direkt von Disk (BinaryImporter), ohne AssetManager-Cache. */
/** Lädt eine Spatial ohne AssetManager-Cache. Unterstützt asset-relative und absolute Pfade. */
private Spatial loadFresh(String assetPath) throws Exception {
// Absoluter Pfad (z. B. externe GLB vom Dateisystem):
// Verzeichnis als FileLocator registrieren, dann per Dateiname laden.
Path absFile = Path.of(assetPath);
if (absFile.isAbsolute()) {
if (!Files.exists(absFile))
throw new java.io.FileNotFoundException("Datei nicht gefunden: " + assetPath);
assets.registerLocator(
absFile.getParent().toAbsolutePath().toString(),
com.jme3.asset.plugins.FileLocator.class);
String fileName = absFile.getFileName().toString();
assets.deleteFromCache(new ModelKey(fileName));
LOG.info("[AnimPreview] Lade extern: {} aus {}", fileName, absFile.getParent());
return assets.loadModel(fileName);
}
// Asset-relativer Pfad:
Path file = ASSET_ROOT.resolve(assetPath.replace('/', java.io.File.separatorChar));
if (assetPath.endsWith(".j3o") && Files.exists(file)) {
BinaryImporter bi = BinaryImporter.getInstance();
@@ -827,12 +884,13 @@ public class AnimPreviewState extends BaseAppState {
int embedded = 0;
for (String clipName : set.getClips()) {
if (!Files.exists(clipsDir.resolve(clipName + ".j3o"))) {
String clipRelPath = resolveClipFile(clipsDir, clipName);
if (clipRelPath == null) {
LOG.warn("[AnimEmbed] Clip nicht gefunden: {}", clipName);
continue;
}
try {
Spatial clipSpatial = loadFresh("animations/clips/" + clipName + ".j3o");
Spatial clipSpatial = loadFresh(clipRelPath);
AnimComposer clipAC = findControl(clipSpatial, AnimComposer.class);
SkinningControl clipSC = findControl(clipSpatial, SkinningControl.class);
if (clipAC == null) continue;
@@ -878,6 +936,15 @@ public class AnimPreviewState extends BaseAppState {
BinaryExporter.getInstance().save(holder, outFile.toFile());
}
/** Returns "animations/clips/<name>.<ext>" for the first matching file, or null if not found. */
private static String resolveClipFile(java.nio.file.Path clipsDir, String clipName) {
for (String ext : new String[]{".j3o", ".glb", ".gltf", ".fbx"}) {
if (Files.exists(clipsDir.resolve(clipName + ext)))
return "animations/clips/" + clipName + ext;
}
return null;
}
private static boolean haveSameBoneNames(com.jme3.anim.Armature a, com.jme3.anim.Armature b) {
if (a.getJointCount() != b.getJointCount()) return false;
java.util.Set<String> namesA = new java.util.HashSet<>();

View File

@@ -59,12 +59,17 @@ public class GrassVertexState extends BaseAppState {
static final ColorRGBA VERY_DRY_ROOT_COLOR = new ColorRGBA(0.18f, 0.09f, 0.02f, 1f);
static final ColorRGBA VERY_DRY_TIP_COLOR = new ColorRGBA(0.38f, 0.20f, 0.05f, 1f);
// ── LOD ───────────────────────────────────────────────────────────────────
private static final float CULL_DIST = 150f;
private static final float CULL_DIST_SQ = CULL_DIST * CULL_DIST;
// ── Zustand ───────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
private final SharedInput input;
private AssetManager assetManager;
private com.jme3.renderer.Camera cam;
private TerrainQuad terrain;
private Node grassNode;
private Material material;
@SuppressWarnings("unchecked")
private final List<GrassVertexBlade>[] chunkBlades = new List[CHUNK_COUNT];
@@ -89,6 +94,7 @@ public class GrassVertexState extends BaseAppState {
@Override
protected void initialize(Application app) {
this.assetManager = app.getAssetManager();
this.cam = app.getCamera();
grassNode = new Node("grassVertexNode");
((SimpleApplication) app).getRootNode().attachChild(grassNode);
material = buildMaterial();
@@ -115,6 +121,22 @@ public class GrassVertexState extends BaseAppState {
public void update(float tpf) {
processBrushEdits();
rebuildDirtyChunks();
updateChunkVisibility();
}
private void updateChunkVisibility() {
if (cam == null) return;
Vector3f camPos = cam.getLocation();
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (chunkNodes[ci] == null) continue;
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float dx = camPos.x - wx, dz = camPos.z - wz;
boolean visible = dx*dx + dz*dz <= CULL_DIST_SQ;
chunkNodes[ci].setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
}
// ── Material ──────────────────────────────────────────────────────────────

View File

@@ -14,8 +14,11 @@ import com.jme3.material.Material;
import com.jme3.math.*;
import com.jme3.renderer.Camera;
import com.jme3.scene.*;
import com.jme3.collision.CollisionResults;
import com.jme3.math.Ray;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Cylinder;
import com.jme3.scene.shape.Dome;
import com.jme3.scene.shape.Sphere;
import com.jme3.util.BufferUtils;
import de.blight.editor.SharedInput;
@@ -67,6 +70,9 @@ public class ModelEditorState extends BaseAppState {
/** Node, der alle Anhang-Gizmos (Lichter + Emitter) enthält. */
private Node attachmentGizmos = null;
/** Hauptlichtquelle der Vorschau (per UI steuerbar). */
private DirectionalLight mainLight = null;
// gespeicherter Kamerazustand aus dem Editor-Modus
private Vector3f savedCamPos;
private Quaternion savedCamRot;
@@ -75,6 +81,12 @@ public class ModelEditorState extends BaseAppState {
private String currentPath = null;
private String mainModelPath = null;
private Node interactableArrowNode = null;
private float interactableOffsetX = 0f;
private float interactableOffsetY = 0.5f;
private float interactableOffsetZ = 0f;
private float interactableRotY = 0f;
/** Originales Spatial wie vom Asset-Manager geladen wird durch LOD-Previews nicht überschrieben. */
private Spatial originalSpatial = null;
@@ -190,6 +202,15 @@ public class ModelEditorState extends BaseAppState {
applyPivot(input.modelEditorPivotY);
}
// Lichtrichtung
if (input.modelEditorLightChanged) {
input.modelEditorLightChanged = false;
if (mainLight != null) {
mainLight.setDirection(computeLightDirection(
input.modelEditorLightAzimuth, input.modelEditorLightElevation));
}
}
// Anhang-Gizmos aktualisieren
if (input.modelEditorAttachmentsChanged) {
input.modelEditorAttachmentsChanged = false;
@@ -233,6 +254,43 @@ public class ModelEditorState extends BaseAppState {
repositionCompCylinder();
}
// Interactable-Ruhepunkt: Position + RotY aus JavaFX übernehmen
if (input.modelInteractableOffsetChanged) {
input.modelInteractableOffsetChanged = false;
interactableOffsetX = input.modelInteractableOffsetX;
interactableOffsetY = input.modelInteractableOffsetY;
interactableOffsetZ = input.modelInteractableOffsetZ;
interactableRotY = input.modelInteractableRotY;
rebuildInteractableArrow();
}
// Interactable-Ruhepunkt: Klick → Raycast gegen Modell
SharedInput.ModelInteractableClick click;
while ((click = input.modelInteractableClickQueue.poll()) != null) {
if (previewRoot == null || modelWrapper == null) break;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
previewRoot.collideWith(ray, hits);
if (hits.size() > 0) {
Vector3f pt = hits.getClosestCollision().getContactPoint();
// Modell-Ursprung berücksichtigen (Pivot-Versatz)
Vector3f modelOrig = modelWrapper.getWorldTranslation();
interactableOffsetX = pt.x - modelOrig.x;
interactableOffsetY = pt.y - modelOrig.y;
interactableOffsetZ = pt.z - modelOrig.z;
input.modelInteractableOffsetX = interactableOffsetX;
input.modelInteractableOffsetY = interactableOffsetY;
input.modelInteractableOffsetZ = interactableOffsetZ;
input.modelInteractableOffsetChanged = true;
input.modelInteractablePosSetFromJme = true;
// Layer zurücksetzen
input.activeLayer = SharedInput.LAYER_MODEL_EDITOR;
rebuildInteractableArrow();
}
}
applyOrbitCamera();
previewRoot.updateLogicalState(tpf);
@@ -382,9 +440,10 @@ public class ModelEditorState extends BaseAppState {
app.getRootNode().setCullHint(Spatial.CullHint.Always);
previewRoot = new Node("model_editor_preview");
previewRoot.addLight(new DirectionalLight(
new Vector3f(-0.5f, -1f, -0.4f).normalizeLocal(),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f)));
mainLight = new DirectionalLight(
computeLightDirection(input.modelEditorLightAzimuth, input.modelEditorLightElevation),
new ColorRGBA(1.8f, 1.7f, 1.4f, 1f));
previewRoot.addLight(mainLight);
previewRoot.addLight(new DirectionalLight(
new Vector3f(0.6f, -0.4f, 0.7f).normalizeLocal(),
new ColorRGBA(0.5f, 0.55f, 0.75f, 1f)));
@@ -393,11 +452,18 @@ public class ModelEditorState extends BaseAppState {
new ColorRGBA(0.4f, 0.4f, 0.5f, 1f)));
previewRoot.addLight(new AmbientLight(new ColorRGBA(0.65f, 0.65f, 0.7f, 1f)));
// Hintergrundfarbe setzen; Viewport-Attach erfolgt NACH dem Modell-Load
// (in loadModel / showSpatialDirectly), damit JME3 beim ersten Render
// eine vollständig initialisierte Szene vorfindet und nicht schwarz rendert.
app.getViewPort().setBackgroundColor(new ColorRGBA(0.15f, 0.15f, 0.18f, 1f));
interactableArrowNode = new Node("interactableArrow");
interactableArrowNode.setCullHint(Spatial.CullHint.Always);
previewRoot.attachChild(interactableArrowNode);
// Offset aus SharedInput übernehmen (gesetzt beim Öffnen des Panels)
interactableOffsetX = input.modelInteractableOffsetX;
interactableOffsetY = input.modelInteractableOffsetY;
interactableOffsetZ = input.modelInteractableOffsetZ;
interactableRotY = input.modelInteractableRotY;
orbitYaw = 30f;
orbitPitch = 25f;
}
@@ -418,6 +484,7 @@ public class ModelEditorState extends BaseAppState {
hasEmbeddedLods = false;
embeddedLodSpatials = null;
originalSpatial = null;
interactableArrowNode = null;
input.modelEditorHasEmbeddedLods = false;
app.getRootNode().setCullHint(Spatial.CullHint.Inherit);
@@ -730,6 +797,81 @@ public class ModelEditorState extends BaseAppState {
}
}
/** Zeichnet / aktualisiert den Ruhepunkt-Pfeil im Modell-Editor. */
public void rebuildInteractableArrow() {
if (interactableArrowNode == null) return;
interactableArrowNode.detachAllChildren();
float shaftLen = 0.8f;
float shaftRad = 0.04f;
float headRad = 0.12f;
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0f, 0.8f, 1f, 1f));
Material matHead = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
matHead.setColor("Color", new ColorRGBA(0f, 0.5f, 1f, 1f));
// Träger-Node an der Ruheposition; alle Kinder liegen lokal entlang +Z
Node group = new Node("arrowGroup");
group.setLocalTranslation(interactableOffsetX, interactableOffsetY, interactableOffsetZ);
float dx = (float) Math.cos(interactableRotY);
float dz = (float) Math.sin(interactableRotY);
Quaternion groupRot = new Quaternion();
groupRot.lookAt(new Vector3f(dx, 0f, dz), Vector3f.UNIT_Y);
group.setLocalRotation(groupRot);
// Marker-Würfel am Ruhepunkt (Ursprung der Gruppe)
Geometry marker = new Geometry("iMarker", new Box(0.06f, 0.06f, 0.06f));
marker.setMaterial(mat);
// Schaft: JME3-Cylinder liegt auf der Z-Achse; Mitte bei shaftLen/2 → geht von 0 bis shaftLen
Geometry shaft = new Geometry("iShaft", new Cylinder(4, 8, shaftRad, shaftLen, true));
shaft.setMaterial(mat);
shaft.setLocalTranslation(0f, 0f, shaftLen * 0.5f);
// Kegelspitze: Dome-Spitze zeigt per Default nach +Y → +90° um X dreht sie nach +Z
Geometry head = new Geometry("iHead", new Dome(Vector3f.ZERO, 2, 8, headRad, false));
head.setMaterial(matHead);
head.setLocalTranslation(0f, 0f, shaftLen);
Quaternion headRot = new Quaternion();
headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot);
group.attachChild(marker);
group.attachChild(shaft);
group.attachChild(head);
interactableArrowNode.attachChild(group);
interactableArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
/** Setzt den Pfeil sichtbar/unsichtbar. */
public void setInteractableArrowVisible(boolean visible) {
if (interactableArrowNode != null)
interactableArrowNode.setCullHint(
visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
/** Initialisiert Offsets aus dem ModelMeta wenn ein neues Modell geöffnet wird. */
public void applyInteractableOffset(float ox, float oy, float oz, float rotY) {
interactableOffsetX = ox;
interactableOffsetY = oy;
interactableOffsetZ = oz;
interactableRotY = rotY;
input.modelInteractableOffsetX = ox;
input.modelInteractableOffsetY = oy;
input.modelInteractableOffsetZ = oz;
input.modelInteractableRotY = rotY;
rebuildInteractableArrow();
}
private Ray screenToRay(float screenX, float screenY) {
Vector3f origin = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 0f);
Vector3f dir = cam.getWorldCoordinates(new com.jme3.math.Vector2f(screenX, screenY), 1f)
.subtractLocal(origin).normalizeLocal();
return new Ray(origin, dir);
}
public String getCurrentPath() { return currentPath; }
/**
@@ -753,4 +895,18 @@ public class ModelEditorState extends BaseAppState {
public BoundingBox getCurrentBounds() {
return getBoundingBox();
}
/**
* Berechnet den normalisierten Richtungsvektor der Lichtquelle aus Azimut und Elevation.
* Azimut 0° = Licht kommt aus Z+; Elevation 90° = Licht kommt senkrecht von oben.
*/
private static Vector3f computeLightDirection(float azimuthDeg, float elevationDeg) {
float azi = (float) Math.toRadians(azimuthDeg);
float ele = (float) Math.toRadians(elevationDeg);
float cosEle = (float) Math.cos(ele);
float dx = -cosEle * (float) Math.sin(azi);
float dy = -(float) Math.sin(ele);
float dz = -cosEle * (float) Math.cos(azi);
return new Vector3f(dx, dy, dz).normalizeLocal();
}
}

View File

@@ -0,0 +1,323 @@
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.scene.shape.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.common.path.PathEdge;
import de.blight.common.path.PathNetwork;
import de.blight.common.path.PathNetworkIO;
import de.blight.common.path.PathNode;
import de.blight.common.model.WorldPoint;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.List;
/**
* JME-AppState für den Wegnetz-Editor.
*
* <p>Interaktionsmodell:
* <ul>
* <li>Linksklick auf leeres Terrain → neuen Knoten platzieren</li>
* <li>Linksklick auf Knoten → Knoten auswählen; ist bereits einer gewählt,
* wird eine Kante zwischen beiden erzeugt</li>
* <li>Linksklick auf Terrain mit gewähltem Knoten → neuen Knoten platzieren
* UND mit dem gewählten Knoten verbinden</li>
* <li>Rechtsklick auf Knoten → Knoten und alle seine Kanten löschen</li>
* <li>Rechtsklick auf Terrain → Auswahl aufheben</li>
* </ul>
*
* Änderungen werden sofort in die Datei geschrieben.
*/
public class PathNetworkEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(PathNetworkEditorState.class);
/** Radius in Welt-Einheiten, innerhalb dessen ein Klick einen Knoten trifft. */
private static final float NODE_HIT_RADIUS = 3f;
private static final float NODE_SPHERE_RADIUS = 0.6f;
private static final float EDGE_RADIUS = 0.15f;
private final SharedInput input;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final PathNetwork network = new PathNetwork();
private PathNode selectedNode = null;
// Szene-Graph-Knoten für Visualisierung
private final Node netRoot = new Node("pathnet_root");
private final Node nodeRoot = new Node("pathnet_nodes");
private final Node edgeRoot = new Node("pathnet_edges");
private Material matDefault;
private Material matSelected;
private Material matEdge;
public PathNetworkEditorState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
SimpleApplication app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
matDefault = mat(new ColorRGBA(1f, 0.85f, 0f, 1f)); // gelb
matSelected = mat(new ColorRGBA(1f, 0.4f, 0f, 1f)); // orange
matEdge = mat(new ColorRGBA(0.3f, 0.9f, 0.3f, 1f)); // grün
netRoot.attachChild(nodeRoot);
netRoot.attachChild(edgeRoot);
try {
PathNetwork loaded = PathNetworkIO.load();
network.getNodes().addAll(loaded.getNodes());
network.getEdges().addAll(loaded.getEdges());
rebuildVisuals();
} catch (IOException e) {
log.warn("Wegnetz nicht ladbar: {}", e.getMessage());
}
}
@Override protected void cleanup(Application app) { netRoot.removeFromParent(); }
@Override
protected void onEnable() {
rootNode.attachChild(netRoot);
}
@Override
protected void onDisable() {
netRoot.removeFromParent();
selectedNode = null;
}
public void setTerrain(TerrainQuad t) { this.terrain = t; }
// ── Update-Loop ────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
SharedInput.PathNetworkClick click;
while ((click = input.pathNetworkClickQueue.poll()) != null) {
handleClick(click);
}
}
private void handleClick(SharedInput.PathNetworkClick 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());
// Knoten in der Nähe des Strahls suchen
PathNode hitNode = findNodeNearRay(ray);
if (click.rightClick()) {
handleRightClick(hitNode);
} else {
handleLeftClick(ray, hitNode);
}
}
private void handleLeftClick(Ray ray, PathNode hitNode) {
if (hitNode != null) {
// Klick auf Knoten
if (selectedNode == null) {
select(hitNode);
} else if (selectedNode == hitNode) {
select(null); // Abwählen
} else {
network.addEdge(selectedNode.getUuid(), hitNode.getUuid());
select(hitNode);
saveAndNotify();
rebuildVisuals();
}
} else {
// Klick auf Terrain → neuer Knoten
WorldPoint pt = terrainHit(ray);
if (pt == null) return;
PathNode newNode = new PathNode(pt);
network.addNode(newNode);
if (selectedNode != null) {
network.addEdge(selectedNode.getUuid(), newNode.getUuid());
}
select(newNode);
saveAndNotify();
rebuildVisuals();
}
}
private void handleRightClick(PathNode hitNode) {
if (hitNode != null) {
if (selectedNode == hitNode) selectedNode = null;
network.removeNode(hitNode.getUuid());
saveAndNotify();
rebuildVisuals();
} else {
select(null);
}
}
// ── Visualisierung ─────────────────────────────────────────────────────────
private void rebuildVisuals() {
nodeRoot.detachAllChildren();
edgeRoot.detachAllChildren();
for (PathNode n : network.getNodes()) {
Geometry g = new Geometry("node_" + n.getUuid(),
new Sphere(8, 8, NODE_SPHERE_RADIUS));
g.setMaterial(n == selectedNode ? matSelected : matDefault);
g.setLocalTranslation(n.getPosition().x, n.getPosition().y + NODE_SPHERE_RADIUS, n.getPosition().z);
g.setUserData("nodeUuid", n.getUuid());
nodeRoot.attachChild(g);
}
for (PathEdge e : network.getEdges()) {
PathNode a = network.nodeById(e.getNodeUuidA());
PathNode b = network.nodeById(e.getNodeUuidB());
if (a == null || b == null) continue;
Geometry g = buildEdgeGeometry(a, b);
if (g != null) edgeRoot.attachChild(g);
}
}
private Geometry buildEdgeGeometry(PathNode a, PathNode b) {
Vector3f va = toVec(a.getPosition());
Vector3f vb = toVec(b.getPosition());
float length = va.distance(vb);
if (length < 0.001f) return null;
Vector3f mid = va.add(vb).multLocal(0.5f);
Vector3f dir = vb.subtract(va).divideLocal(length); // normalisiert
Cylinder cyl = new Cylinder(4, 8, EDGE_RADIUS, length, true);
Geometry g = new Geometry("edge_" + a.getUuid() + "_" + b.getUuid(), cyl);
g.setMaterial(matEdge);
g.setLocalTranslation(mid);
// Der JME-Zylinder liegt standardmäßig entlang seiner lokalen Y-Achse.
// Wir brauchen eine Rotation, die UNIT_Y auf `dir` dreht.
// Achse = UNIT_Y × dir, Winkel = arccos(UNIT_Y · dir)
g.setLocalRotation(rotationYToDir(dir));
return g;
}
/**
* Berechnet die Quaternion, die UNIT_Y (0,1,0) auf {@code dir} dreht.
* {@code dir} muss bereits normalisiert sein.
*/
private static Quaternion rotationYToDir(Vector3f dir) {
// Kreuzprodukt liefert die Rotationsachse; Skalarprodukt den cos(Winkel)
Vector3f axis = new Vector3f(-dir.z, 0f, dir.x); // UNIT_Y × dir = (-dz, 0, dx)
float sinA = axis.length(); // |UNIT_Y × dir| = sin(angle)
float cosA = dir.y; // UNIT_Y · dir = cos(angle)
if (sinA < 0.0001f) {
// dir ist (anti-)parallel zu UNIT_Y
return cosA >= 0f
? new Quaternion(0f, 0f, 0f, 1f) // identisch
: new Quaternion().fromAngleNormalAxis(FastMath.PI, Vector3f.UNIT_X); // 180° flip
}
axis.divideLocal(sinA); // normalisieren
float angle = (float) Math.atan2(sinA, cosA);
return new Quaternion().fromAngleNormalAxis(angle, axis);
}
private void updateNodeMaterial(PathNode node) {
Geometry g = (Geometry) nodeRoot.getChild("node_" + node.getUuid());
if (g != null) g.setMaterial(node == selectedNode ? matSelected : matDefault);
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
private PathNode findNodeNearRay(Ray ray) {
PathNode best = null;
float bestDist = NODE_HIT_RADIUS;
for (PathNode n : network.getNodes()) {
Vector3f pos = toVec(n.getPosition());
float d = ray.distanceSquared(pos);
float distFromOrigin = pos.subtract(ray.getOrigin()).length();
float threshold = NODE_HIT_RADIUS * (distFromOrigin / 100f + 1f);
if (d < threshold * threshold && d < bestDist * bestDist) {
bestDist = (float) Math.sqrt(d);
best = n;
}
}
return best;
}
private WorldPoint terrainHit(Ray ray) {
if (terrain == null) return null;
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return null;
Vector3f pt = hits.getClosestCollision().getContactPoint();
return new WorldPoint(pt.x, pt.y, pt.z);
}
private void select(PathNode node) {
PathNode prev = selectedNode;
selectedNode = node;
if (prev != null) updateNodeMaterial(prev);
if (node != null) updateNodeMaterial(node);
}
private void saveAndNotify() {
try {
PathNetworkIO.save(network);
} catch (IOException e) {
log.error("Wegnetz speichern fehlgeschlagen: {}", e.getMessage());
}
input.pathNetworkChanged = true;
}
private Material mat(ColorRGBA color) {
Material m = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
m.setColor("Color", color);
return m;
}
private static Vector3f toVec(WorldPoint p) {
return new Vector3f(p.x, p.y, p.z);
}
/** Liefert eine Kopie des aktuellen Netzes (für UI-Anzeige). */
public PathNetwork getNetwork() { return network; }
/** Löscht den gewählten Knoten (aufrufbar aus JavaFX via enqueue). */
public void deleteSelected() {
if (selectedNode == null) return;
network.removeNode(selectedNode.getUuid());
selectedNode = null;
saveAndNotify();
rebuildVisuals();
}
/** Benennt den gewählten Knoten um. */
public void renameSelected(String name) {
if (selectedNode == null) return;
selectedNode.setName(name);
saveAndNotify();
}
}

View File

@@ -0,0 +1,106 @@
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.Sphere;
import com.jme3.terrain.geomipmap.TerrainQuad;
import de.blight.editor.SharedInput;
import java.util.ArrayList;
import java.util.List;
/**
* JME-AppState für den Tagesablauf-Editor.
* Verarbeitet Terrain-Klicks für die Punktauswahl und zeigt
* Waypoint-Marker in der Szene an.
*/
public class RoutineMapState extends BaseAppState {
private final SharedInput input;
private Camera cam;
private AssetManager assets;
private Node rootNode;
private TerrainQuad terrain;
private final List<Geometry> waypointMarkers = new ArrayList<>();
private Material markerMat;
public RoutineMapState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
SimpleApplication app = (SimpleApplication) application;
cam = app.getCamera();
assets = app.getAssetManager();
rootNode = app.getRootNode();
markerMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
markerMat.setColor("Color", new ColorRGBA(1f, 0.5f, 0f, 1f));
}
@Override protected void cleanup(Application app) { clearMarkers(); }
@Override protected void onEnable() {}
@Override protected void onDisable() {}
public void setTerrain(TerrainQuad t) { this.terrain = t; }
@Override
public void update(float tpf) {
SharedInput.RoutinePointClick click;
while ((click = input.routinePointClickQueue.poll()) != null) {
handleClick(click);
}
}
private void handleClick(SharedInput.RoutinePointClick click) {
if (terrain == null) return;
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());
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) return;
Vector3f pt = hits.getClosestCollision().getContactPoint();
input.routinePickedPoint = pt.x + "|" + pt.y + "|" + pt.z;
input.routinePickedChanged = true;
}
/**
* Aktualisiert die Waypoint-Marker in der Szene.
* Wird von JavaFX-Thread via SharedInput-Koordinaten nicht direkt aufgerufen;
* stattdessen liest die View die Koordinaten und ruft diese Methode
* über Platform.runLater nicht auf die Marker werden im JME-Thread platziert.
*
* Format: Liste von "x|y|z" Strings.
*/
public void showMarkers(List<float[]> points) {
clearMarkers();
for (float[] p : points) {
Geometry g = new Geometry("routine_marker", new Sphere(8, 8, 0.6f));
g.setMaterial(markerMat);
g.setLocalTranslation(p[0], p[1] + 0.6f, p[2]);
rootNode.attachChild(g);
waypointMarkers.add(g);
}
}
public void clearMarkers() {
for (Geometry g : waypointMarkers) rootNode.detachChild(g);
waypointMarkers.clear();
}
}

View File

@@ -24,7 +24,12 @@ import com.jme3.scene.shape.Torus;
import com.jme3.texture.Texture;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.scene.shape.Dome;
import de.blight.common.PlacedModel;
import de.blight.common.model.Bed;
import de.blight.common.model.BedIO;
import de.blight.common.model.Bench;
import de.blight.common.model.BenchIO;
import de.blight.editor.SharedInput;
import de.blight.editor.object.SceneObject;
@@ -104,6 +109,20 @@ public class SceneObjectState extends BaseAppState {
private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt)
private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi)
/** Kleiner Cache: modelPath → ModelMeta, damit nicht pro Frame geladen werden muss. */
private final java.util.Map<String, de.blight.common.ModelMeta> metaCache = new java.util.HashMap<>();
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/** Visualisierungspfeil für die Liegefläche (1,8 m); wird nur gezeigt wenn Bett-Objekt gewählt. */
private Node bedArrowNode = null;
/** UUID des Bettes, für das der Pfeil gerade angezeigt wird. */
private String bedArrowBedId = null;
/** Visualisierungspfeil für die Sitzfläche (0,5 m); wird nur gezeigt wenn Bank-Objekt gewählt. */
private Node benchArrowNode = null;
/** UUID der Bank, für die der Pfeil gerade angezeigt wird. */
private String benchArrowBenchId = null;
private int subTriIdx = -1;
private int[] subEdgeVertIdx = null; // [v0, v1] Vertex-Indizes im Mesh (Kanten-Modus)
private int subVertexIdx = -1; // einzelner Vertex-Index (Punkt-Modus)
@@ -151,13 +170,15 @@ public class SceneObjectState extends BaseAppState {
meshFile, animClips.get(i),
so.castShadow, so.receiveShadow,
so.lod1Path, so.lod2Path,
so.lod1Distance, so.lod2Distance, so.cullDistance));
so.lod1Distance, so.lod2Distance, so.cullDistance,
so.interactableType, so.interactableId));
}
return list;
}
public void loadPlacedModels(List<PlacedModel> models) {
if (objectRoot == null) return;
metaCache.clear();
objectRoot.detachAllChildren();
objects.clear();
objNodes.clear();
@@ -177,9 +198,11 @@ public class SceneObjectState extends BaseAppState {
so.receiveShadow = pm.receiveShadow();
so.lod1Path = pm.lod1Path() != null ? pm.lod1Path() : "";
so.lod2Path = pm.lod2Path() != null ? pm.lod2Path() : "";
so.lod1Distance = pm.lod1Distance();
so.lod2Distance = pm.lod2Distance();
so.cullDistance = pm.cullDistance();
so.lod1Distance = pm.lod1Distance();
so.lod2Distance = pm.lod2Distance();
so.cullDistance = pm.cullDistance();
so.interactableType = pm.interactableType() != null ? pm.interactableType() : "";
so.interactableId = pm.interactableId() != null ? pm.interactableId() : "";
objects.add(so);
animClips.add(pm.animClip() != null ? pm.animClip() : "");
@@ -246,6 +269,14 @@ public class SceneObjectState extends BaseAppState {
subOverlay = new Node("subOverlay");
subOverlay.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(subOverlay);
bedArrowNode = new Node("bedArrow");
bedArrowNode.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(bedArrowNode);
benchArrowNode = new Node("benchArrow");
benchArrowNode.setCullHint(Spatial.CullHint.Always);
rootNode.attachChild(benchArrowNode);
}
@Override
@@ -254,6 +285,8 @@ public class SceneObjectState extends BaseAppState {
gizmoNode.removeFromParent();
previewNode.removeFromParent();
subOverlay.removeFromParent();
bedArrowNode.removeFromParent();
benchArrowNode.removeFromParent();
}
@Override protected void onEnable() {}
@@ -294,6 +327,11 @@ public class SceneObjectState extends BaseAppState {
try { loadPlacedModels(de.blight.common.PlacedModelIO.load()); } catch (Exception ignored) {}
}
// Bett-Liegefläche platzieren
handleBedLiegeLayer();
// Bank-Sitzfläche platzieren
handleBenchSitzLayer();
boolean isObjectLayer = input.activeLayer == SharedInput.LAYER_OBJECTS
|| input.activeLayer == SharedInput.LAYER_OBJECTS_EDIT;
if (!isObjectLayer) return;
@@ -309,6 +347,21 @@ public class SceneObjectState extends BaseAppState {
}
}
// Interactable-Zuweisung von JavaFX
String pendingIType = input.pendingInteractableType;
String pendingIId = input.pendingInteractableId;
if (pendingIType != null && pendingIId != null) {
input.pendingInteractableType = null;
input.pendingInteractableId = null;
if (!selectedIndices.isEmpty()) {
SceneObject so = objects.get(selectedIndices.get(0));
so.interactableType = pendingIType;
so.interactableId = pendingIId;
}
refreshBedArrow();
refreshBenchArrow();
}
// Solid-Flag-Änderung von JavaFX
Boolean solidChange = input.pendingSolidChange;
if (solidChange != null) {
@@ -590,6 +643,33 @@ public class SceneObjectState extends BaseAppState {
so.lod1Distance = meta.lod1Distance();
so.lod2Distance = meta.lod2Distance();
so.cullDistance = meta.cullDistance();
if (meta.interactableType() != null
&& meta.interactableType() != de.blight.common.model.InteractableType.NONE) {
so.interactableType = meta.interactableType().name();
// Bed/Bench-Instanz mit Welt-Koordinaten (Modellpos + rotierter Offset) anlegen
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx2 = wx + ox * cos - oz * sin;
float wy2 = wy + placementOffY + oy;
float wz2 = wz + ox * sin + oz * cos;
float roty2 = rotY + meta.interactableRotY();
if (meta.interactableType() == de.blight.common.model.InteractableType.BED) {
Bed bed = new Bed();
bed.setLiegeX(wx2); bed.setLiegeY(wy2); bed.setLiegeZ(wz2);
bed.setLiegeRotY(roty2); bed.setLiegeSet(true);
try { BedIO.save(bed); so.interactableId = bed.getId(); }
catch (java.io.IOException e) { log.error("BedIO save: {}", e.getMessage()); }
} else if (meta.interactableType() == de.blight.common.model.InteractableType.BENCH) {
Bench bench = new Bench();
bench.setSitzX(wx2); bench.setSitzY(wy2); bench.setSitzZ(wz2);
bench.setSitzRotY(roty2); bench.setSitzSet(true);
try { BenchIO.save(bench); so.interactableId = bench.getId(); }
catch (java.io.IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
}
}
}
so.setRotation(0f, rotY, 0f);
so.setScale(defaultScale);
@@ -885,11 +965,14 @@ public class SceneObjectState extends BaseAppState {
+ "|" + so.getScale() + "|" + so.getTexturePath()
+ "|" + so.getNormalMapPath() + "|" + so.getMaterialPath()
+ "|" + animClips.get(idx)
+ "|" + so.castShadow + "|" + so.receiveShadow;
+ "|" + so.castShadow + "|" + so.receiveShadow
+ "|" + so.interactableType + "|" + so.interactableId;
} else {
input.selectedObjectInfo = String.valueOf(n);
}
input.objectSelectionChanged = true;
refreshBedArrow();
refreshBenchArrow();
}
// ── Gizmo-Drag ───────────────────────────────────────────────────────────
@@ -1430,6 +1513,8 @@ public class SceneObjectState extends BaseAppState {
sortedDesc.sort(Comparator.reverseOrder());
for (int idx : sortedDesc) {
SceneObject so = objects.get(idx);
deleteInteractableFile(so);
objectRoot.detachChild(objNodes.get(idx));
objects.remove(idx);
objNodes.remove(idx);
@@ -1446,6 +1531,69 @@ public class SceneObjectState extends BaseAppState {
setStatus("Objekt(e) gelöscht");
}
private void deleteInteractableFile(SceneObject so) {
if (so.interactableId == null || so.interactableId.isEmpty()) return;
try {
if ("BED".equalsIgnoreCase(so.interactableType)) BedIO.delete(so.interactableId);
else if ("BENCH".equalsIgnoreCase(so.interactableType)) BenchIO.delete(so.interactableId);
} catch (java.io.IOException e) {
log.warn("[SceneObject] Interactable-Datei nicht gelöscht: {}", e.getMessage());
}
}
/**
* Synchronisiert alle Bed/Bench-JSON-Dateien mit den aktuellen
* Positionen und Rotationen der platzierten Objekte.
* Wird vor dem Karten-Speichern aufgerufen.
*/
public void syncInteractables() {
for (SceneObject so : objects) {
if (so.interactableId == null || so.interactableId.isEmpty()) continue;
String itype = so.interactableType;
if (itype == null || itype.isEmpty()) continue;
boolean isBed = "BED".equalsIgnoreCase(itype);
boolean isBench = "BENCH".equalsIgnoreCase(itype);
if (!isBed && !isBench) continue;
// Modell-Meta laden (enthält lokale Offsets des Ruhepunkts)
Path modelPath = ASSET_ROOT.resolve(so.modelPath);
if (!java.nio.file.Files.exists(modelPath)) continue;
de.blight.common.ModelMeta meta = de.blight.common.ModelMetaIO.load(modelPath);
float rotY = so.getRotY();
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx = so.getWorldX() + ox * cos - oz * sin;
float wy = so.getGroundY() + oy;
float wz = so.getWorldZ() + ox * sin + oz * cos;
float iRotY = rotY + meta.interactableRotY();
try {
if (isBed) {
BedIO.load(so.interactableId).ifPresent(bed -> {
bed.setLiegeX(wx); bed.setLiegeY(wy); bed.setLiegeZ(wz);
bed.setLiegeRotY(iRotY); bed.setLiegeSet(true);
try { BedIO.save(bed); }
catch (java.io.IOException e) { log.error("[SceneObject] Bett sync: {}", e.getMessage()); }
});
} else {
BenchIO.load(so.interactableId).ifPresent(bench -> {
bench.setSitzX(wx); bench.setSitzY(wy); bench.setSitzZ(wz);
bench.setSitzRotY(iRotY); bench.setSitzSet(true);
try { BenchIO.save(bench); }
catch (java.io.IOException e) { log.error("[SceneObject] Bank sync: {}", e.getMessage()); }
});
}
} catch (Exception e) {
log.error("[SceneObject] syncInteractables Fehler: {}", e.getMessage());
}
}
}
// ── Zusammenfassen ────────────────────────────────────────────────────────
private void mergeSelected() {
@@ -1932,4 +2080,242 @@ public class SceneObjectState extends BaseAppState {
local.z /= wScale.z;
return local;
}
// ── Bett-Liegefläche ─────────────────────────────────────────────────────
/**
* Verarbeitet Terrain-Klicks im LAYER_BED_LIEGE-Modus und Rotations-Änderungen.
* Wird in update() immer aufgerufen (unabhängig von isObjectLayer).
*/
private void handleBedLiegeLayer() {
// Rotations-Update von JavaFX (kann immer ankommen, auch ohne Klick)
Float rotPending = input.pendingBedLiegeRotY;
if (rotPending != null) {
input.pendingBedLiegeRotY = null;
String bedId = input.bedLiegeTargetId;
if (bedId != null && !bedId.isBlank()) {
Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId));
bed.setLiegeRotY(rotPending);
if (!bed.isLiegeSet()) { bed.setLiegeSet(true); }
try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); }
placeBedArrow(bed);
}
}
if (input.activeLayer != SharedInput.LAYER_BED_LIEGE) return;
SharedInput.BedLiegeClick click;
while ((click = input.bedLiegeClickQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f pt = hits.getClosestCollision().getContactPoint();
String bedId = input.bedLiegeTargetId;
if (bedId == null || bedId.isBlank()) continue;
Bed bed = BedIO.load(bedId).orElseGet(() -> new Bed(bedId));
bed.setLiegeX(pt.x);
bed.setLiegeY(pt.y);
bed.setLiegeZ(pt.z);
bed.setLiegeSet(true);
try { BedIO.save(bed); } catch (IOException e) { log.error("BedIO save: {}", e.getMessage()); }
input.bedLiegePickResult = pt.x + "|" + pt.y + "|" + pt.z;
input.bedLiegePickChanged = true;
// Zurück zu Objekt-Bearbeitung
input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT;
placeBedArrow(bed);
}
}
private de.blight.common.ModelMeta loadMetaCached(String modelPath) {
return metaCache.computeIfAbsent(modelPath, p -> {
Path full = ASSET_ROOT.resolve(p);
if (!java.nio.file.Files.exists(full)) return null;
return de.blight.common.ModelMetaIO.load(full);
});
}
/** Gibt [wx, wy, wz, totalRotY] aus SceneObject-Transform + ModelMeta zurück, oder null wenn kein Meta. */
private float[] computeInteractableWorldPos(SceneObject so) {
de.blight.common.ModelMeta meta = loadMetaCached(so.modelPath);
if (meta == null) return null;
float rotY = so.getRotY();
float cos = (float) Math.cos(rotY);
float sin = (float) Math.sin(rotY);
float ox = meta.interactableOffsetX();
float oy = meta.interactableOffsetY();
float oz = meta.interactableOffsetZ();
float wx = so.getWorldX() + ox * cos - oz * sin;
float wy = so.getGroundY() + oy;
float wz = so.getWorldZ() + ox * sin + oz * cos;
return new float[]{wx, wy, wz, rotY + meta.interactableRotY()};
}
/**
* Aktualisiert die Bett-Pfeil-Visualisierung für das aktuell gewählte Objekt.
* Zeigt den Pfeil wenn genau ein Objekt gewählt ist, es ein Bett ist und die Liegefläche gesetzt.
*/
private void refreshBedArrow() {
if (selectedIndices.size() != 1) { hideBedArrow(); return; }
SceneObject so = objects.get(selectedIndices.get(0));
if (!"BED".equals(so.interactableType) || so.interactableId.isBlank()) { hideBedArrow(); return; }
bedArrowBedId = so.interactableId;
// Weltkoordinaten live aus aktuellem Transform + Meta berechnen
float[] wp = computeInteractableWorldPos(so);
if (wp != null) {
placeBedArrow(wp[0], wp[1], wp[2], wp[3]);
} else {
// Fallback: aus JSON (z.B. manuell gesetztes Bett ohne Meta-Offset)
Bed bed = BedIO.load(so.interactableId).orElse(null);
if (bed == null || !bed.isLiegeSet()) { hideBedArrow(); return; }
placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY());
}
}
private void hideBedArrow() {
bedArrowNode.detachAllChildren();
bedArrowNode.setCullHint(Spatial.CullHint.Always);
bedArrowBedId = null;
}
private void placeBedArrow(Bed bed) {
placeBedArrow(bed.getLiegeX(), bed.getLiegeY(), bed.getLiegeZ(), bed.getLiegeRotY());
}
private void placeBedArrow(float wx, float wy, float wz, float rotY) {
bedArrowNode.detachAllChildren();
Node g = buildDirectionalArrow(1.5f, 0.06f, 0.18f,
new ColorRGBA(1f, 0.5f, 0f, 1f),
new ColorRGBA(1f, 0.2f, 0f, 1f));
g.setLocalTranslation(wx, wy + 0.05f, wz);
Quaternion q = new Quaternion();
q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y);
g.setLocalRotation(q);
bedArrowNode.attachChild(g);
bedArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
/**
* Baut einen Pfeil entlang der lokalen +Z-Achse:
* Schaft (Cylinder, zentriert bei z=0) + Kegelspitze am positiven Ende.
* Der Aufrufer positioniert und rotiert den zurückgegebenen Node.
*/
private Node buildDirectionalArrow(float shaftLen, float shaftRad, float headRad,
ColorRGBA shaftColor, ColorRGBA headColor) {
Node group = new Node("arrowGroup");
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", shaftColor);
Material matH = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
matH.setColor("Color", headColor);
// Schaft: JME3-Cylinder liegt auf der Z-Achse, zentriert bei z=0
Geometry shaft = new Geometry("shaft", new Cylinder(4, 8, shaftRad, shaftLen, true));
shaft.setMaterial(mat);
// Kegelspitze am positiven Ende: Dome-Spitze zeigt +Y per Default;
// +90° um X dreht sie nach +Z (vorwärts)
Geometry head = new Geometry("head", new Dome(Vector3f.ZERO, 2, 8, headRad, false));
head.setMaterial(matH);
head.setLocalTranslation(0f, 0f, shaftLen * 0.5f);
Quaternion headRot = new Quaternion();
headRot.fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X);
head.setLocalRotation(headRot);
group.attachChild(shaft);
group.attachChild(head);
return group;
}
// ── Bank-Sitzfläche ───────────────────────────────────────────────────────
private void handleBenchSitzLayer() {
Float rotPending = input.pendingBenchSitzRotY;
if (rotPending != null) {
input.pendingBenchSitzRotY = null;
String benchId = input.benchSitzTargetId;
if (benchId != null && !benchId.isBlank()) {
Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId));
bench.setSitzRotY(rotPending);
if (!bench.isSitzSet()) bench.setSitzSet(true);
try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
placeBenchArrow(bench);
}
}
if (input.activeLayer != SharedInput.LAYER_BENCH_SITZ) return;
SharedInput.BenchSitzClick click;
while ((click = input.benchSitzClickQueue.poll()) != null) {
if (terrain == null) continue;
float jmeX = click.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - click.screenY() * (float) input.viewportScaleY;
Ray ray = screenToRay(jmeX, jmeY);
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f pt = hits.getClosestCollision().getContactPoint();
String benchId = input.benchSitzTargetId;
if (benchId == null || benchId.isBlank()) continue;
Bench bench = BenchIO.load(benchId).orElseGet(() -> new Bench(benchId));
bench.setSitzX(pt.x);
bench.setSitzY(pt.y);
bench.setSitzZ(pt.z);
bench.setSitzSet(true);
try { BenchIO.save(bench); } catch (IOException e) { log.error("BenchIO save: {}", e.getMessage()); }
input.benchSitzPickResult = pt.x + "|" + pt.y + "|" + pt.z;
input.benchSitzPickChanged = true;
input.activeLayer = SharedInput.LAYER_OBJECTS_EDIT;
placeBenchArrow(bench);
}
}
private void refreshBenchArrow() {
if (selectedIndices.size() != 1) { hideBenchArrow(); return; }
SceneObject so = objects.get(selectedIndices.get(0));
if (!"BENCH".equals(so.interactableType) || so.interactableId.isBlank()) { hideBenchArrow(); return; }
benchArrowBenchId = so.interactableId;
float[] wp = computeInteractableWorldPos(so);
if (wp != null) {
placeBenchArrow(wp[0], wp[1], wp[2], wp[3]);
} else {
Bench bench = BenchIO.load(so.interactableId).orElse(null);
if (bench == null || !bench.isSitzSet()) { hideBenchArrow(); return; }
placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY());
}
}
private void hideBenchArrow() {
benchArrowNode.detachAllChildren();
benchArrowNode.setCullHint(Spatial.CullHint.Always);
benchArrowBenchId = null;
}
private void placeBenchArrow(Bench bench) {
placeBenchArrow(bench.getSitzX(), bench.getSitzY(), bench.getSitzZ(), bench.getSitzRotY());
}
private void placeBenchArrow(float wx, float wy, float wz, float rotY) {
benchArrowNode.detachAllChildren();
Node g = buildDirectionalArrow(0.4f, 0.04f, 0.10f,
new ColorRGBA(0.2f, 0.6f, 1f, 1f),
new ColorRGBA(0f, 0.3f, 1f, 1f));
g.setLocalTranslation(wx, wy + 0.05f, wz);
Quaternion q = new Quaternion();
q.lookAt(new Vector3f((float) Math.cos(rotY), 0f, (float) Math.sin(rotY)), Vector3f.UNIT_Y);
g.setLocalRotation(q);
benchArrowNode.attachChild(g);
benchArrowNode.setCullHint(Spatial.CullHint.Inherit);
}
}

View File

@@ -0,0 +1,743 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.collision.CollisionResults;
import com.jme3.export.binary.BinaryImporter;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.util.BufferUtils;
import de.blight.common.SculptedMesh;
import de.blight.common.SculptedMeshIO;
import de.blight.common.VoxelChunk;
import de.blight.common.VoxelChunkIO;
import de.blight.editor.SharedInput;
import de.blight.editor.tool.SculptMeshTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.nio.file.Path;
import java.util.*;
/**
* Ermöglicht das direkte Sculpten der gebackenen Voxel-Meshes.
* Aktiv wenn {@link SharedInput#LAYER_SCULPT} aktiv ist.
*
* Vertices gleicher Position werden beim Laden verschweißt (welded), sodass
* der Pinsel alle Dreiecke, die einen Punkt teilen, zusammen bewegt und keine
* Löcher entstehen. Für den GPU-Upload wird das welded Set wieder auf die
* originale Triangle-Soup aufgefächert.
*/
public class SculptedMeshEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(SculptedMeshEditorState.class);
private static final int MAX_UNDO = 10;
private static final float SAVE_DELAY = 3f;
private final SharedInput input;
private SimpleApplication app;
private Camera cam;
private Node sculptRoot;
private Geometry brushIndicator;
private boolean active;
private final Map<Long, EditableMesh> meshes = new LinkedHashMap<>();
private final Map<Long, float[]> sessionSnapshot = new HashMap<>();
private final Deque<Map<Long, float[]>> undoStack = new ArrayDeque<>();
private final Deque<Map<Long, float[]>> redoStack = new ArrayDeque<>();
private float dirtyTimer = 0f;
private boolean hasDirty = false;
private long selectedKey = -1L;
private Material normalMat = null;
private Material highlightMat = null;
// ── innere Klasse ─────────────────────────────────────────────────────────
private static final class EditableMesh {
final int cx, cy, cz;
// Roh-Daten aus dem j3o (Triangle Soup):
final int rawCount; // Anzahl roher Vertices
final int[] v2w; // v2w[rawIdx] = welded Index
// Verschweißte Daten (Sculpt-Ziel):
float[] wp; // weldedCount × 3 Positionen
float[] wn; // weldedCount × 3 Normalen
final int[] wi; // weldedCount Dreiecks-Indices (= rawCount, aber auf welded gemappt)
final int[][] wnb; // Nachbarn pro welded Vertex
// Aufgefächerte Daten für GPU-Upload:
final float[] rp; // rawCount × 3 Positionen
final float[] rn; // rawCount × 3 Normalen
Geometry geo;
Node node;
EditableMesh(int cx, int cy, int cz,
int rawCount, int[] v2w,
float[] wp, float[] wn, int[] wi, int[][] wnb) {
this.cx = cx; this.cy = cy; this.cz = cz;
this.rawCount = rawCount;
this.v2w = v2w;
this.wp = wp; this.wn = wn;
this.wi = wi; this.wnb = wnb;
this.rp = new float[rawCount * 3];
this.rn = new float[rawCount * 3];
}
}
// ── Konstruktor / Lebenszyklus ────────────────────────────────────────────
public SculptedMeshEditorState(SharedInput input) {
this.input = input;
}
@Override
protected void initialize(Application application) {
app = (SimpleApplication) application;
cam = app.getCamera();
sculptRoot = new Node("sculptRoot");
app.getRootNode().attachChild(sculptRoot);
highlightMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
highlightMat.setColor("Color", new ColorRGBA(1f, 0.65f, 0f, 1f));
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
rescanBakedChunks();
}
@Override
protected void cleanup(Application application) {
saveAllDirty();
sculptRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
meshes.clear();
}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
boolean shouldBeActive = (input.activeLayer == SharedInput.LAYER_SCULPT);
if (shouldBeActive != active) {
active = shouldBeActive;
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
if (ves != null) ves.setChunksVisible(!active);
if (active) rescanBakedChunks();
}
if (input.sculptRescanNeeded) {
input.sculptRescanNeeded = false;
rescanBakedChunks();
}
updateBrushIndicator();
if (!active) return;
if (input.sculptUndoRequested) { input.sculptUndoRequested = false; doUndo(); }
if (input.sculptRedoRequested) { input.sculptRedoRequested = false; doRedo(); }
if (input.sculptActionStarted) {
input.sculptActionStarted = false;
sessionSnapshot.clear();
redoStack.clear();
}
if (input.sculptActionFinished) {
input.sculptActionFinished = false;
if (!sessionSnapshot.isEmpty()) {
if (undoStack.size() >= MAX_UNDO) undoStack.pollFirst();
undoStack.addLast(new HashMap<>(sessionSnapshot));
}
sessionSnapshot.clear();
}
if (input.sculptApplyTranslate) {
input.sculptApplyTranslate = false;
if (selectedKey != -1L) applyTranslate(selectedKey,
input.sculptTranslateX, input.sculptTranslateY, input.sculptTranslateZ);
}
if (input.sculptApplyRotate) {
input.sculptApplyRotate = false;
if (selectedKey != -1L) applyRotateY(selectedKey, input.sculptRotateDeg);
}
if (input.sculptDeleteSelected) {
input.sculptDeleteSelected = false;
if (selectedKey != -1L) deleteSelected();
}
SharedInput.SculptEdit edit;
while ((edit = input.sculptEditQueue.poll()) != null) {
applyBrushEdit(edit);
}
if (hasDirty) {
dirtyTimer += tpf;
if (dirtyTimer >= SAVE_DELAY) {
saveAllDirty();
hasDirty = false;
dirtyTimer = 0f;
}
}
}
// ── Scan / Laden ──────────────────────────────────────────────────────────
private void rescanBakedChunks() {
for (int[] cxyz : SculptedMeshIO.findAllBakedChunks()) {
long key = chunkKey(cxyz[0], cxyz[1], cxyz[2]);
if (meshes.containsKey(key)) continue;
try {
loadChunk(cxyz[0], cxyz[1], cxyz[2], key);
} catch (Exception e) {
log.warn("Sculpt-Chunk laden fehlgeschlagen ({},{},{}): {}",
cxyz[0], cxyz[1], cxyz[2], e.getMessage());
}
}
}
private void loadChunk(int cx, int cy, int cz, long key) throws Exception {
Path p = VoxelChunkIO.getBakedPath(cx, cy, cz, 0);
BinaryImporter imp = BinaryImporter.getInstance();
imp.setAssetManager(app.getAssetManager());
com.jme3.scene.Mesh m = (com.jme3.scene.Mesh) imp.load(p.toFile());
// Roh-Vertex-Daten aus j3o (Triangle Soup mit sequenziellen Indices)
FloatBuffer posBuf = m.getFloatBuffer(VertexBuffer.Type.Position);
int rawCount = posBuf.limit() / 3;
float[] rawPos = new float[rawCount * 3];
posBuf.rewind(); posBuf.get(rawPos);
// Vertices gleicher Position verschweißen
int[] v2w = new int[rawCount];
float[] tmpWp = new float[rawCount * 3];
int wCount = 0;
HashMap<Long, Integer> key2w = new HashMap<>(rawCount / 3 + 16);
for (int v = 0; v < rawCount; v++) {
long pk = posKey(rawPos, v);
Integer w = key2w.get(pk);
if (w == null) {
key2w.put(pk, wCount);
tmpWp[wCount * 3] = rawPos[v * 3];
tmpWp[wCount * 3 + 1] = rawPos[v * 3 + 1];
tmpWp[wCount * 3 + 2] = rawPos[v * 3 + 2];
v2w[v] = wCount++;
} else {
v2w[v] = w;
}
}
float[] wp = Arrays.copyOf(tmpWp, wCount * 3);
// Welded Dreiecks-Indices (die originalen sind 0,1,2,...,N-1 → einfach v2w anwenden)
int[] wi = new int[rawCount];
for (int v = 0; v < rawCount; v++) wi[v] = v2w[v];
// Sculpt-Overlay laden (wenn vorhanden, enthält welded Positionen)
if (SculptedMeshIO.exists(cx, cy, cz)) {
try {
SculptedMesh overlay = SculptedMeshIO.load(cx, cy, cz);
if (overlay.positions.length == wp.length) {
System.arraycopy(overlay.positions, 0, wp, 0, wp.length);
} else {
log.warn("Sculpt-Overlay ({},{},{}) hat falsches Format ({} statt {}), ignoriert.",
cx, cy, cz, overlay.positions.length, wp.length);
}
} catch (Exception e) {
log.warn("Sculpt-Overlay ({},{},{}) fehlerhaft: {}", cx, cy, cz, e.getMessage());
}
}
// Normalen für welded Mesh berechnen
float[] wn = new float[wCount * 3];
recomputeNormals(wp, wn, wi, wCount);
// Nachbarn für welded Mesh aufbauen
int[][] wnb = buildNeighbors(wCount, wi);
// GPU-Buffers auf Dynamic setzen
m.getBuffer(VertexBuffer.Type.Position).setUsage(VertexBuffer.Usage.Dynamic);
if (m.getBuffer(VertexBuffer.Type.Normal) != null)
m.getBuffer(VertexBuffer.Type.Normal).setUsage(VertexBuffer.Usage.Dynamic);
// Material von VoxelEditorState wiederverwenden
VoxelEditorState ves = app.getStateManager().getState(VoxelEditorState.class);
Material mat = (ves != null) ? ves.getVoxelMaterial() : null;
if (mat == null) mat = new Material(app.getAssetManager(), "MatDefs/Voxel.j3md");
if (normalMat == null) normalMat = mat;
Geometry geo = new Geometry("sculpt_" + cx + "_" + cy + "_" + cz, m);
geo.setMaterial(mat);
float ox = cx * VoxelChunk.CELLS - 2048f;
float oy = cy * (float) VoxelChunk.CELLS;
float oz = cz * VoxelChunk.CELLS - 2048f;
Node node = new Node("sculptNode_" + cx + "_" + cy + "_" + cz);
node.setLocalTranslation(ox, oy, oz);
node.attachChild(geo);
sculptRoot.attachChild(node);
EditableMesh em = new EditableMesh(cx, cy, cz, rawCount, v2w, wp, wn, wi, wnb);
em.geo = geo;
em.node = node;
// GPU-Buffers initial befüllen
expandAndUpload(em, m);
meshes.put(key, em);
log.info("Sculpt-Chunk geladen ({},{},{}): {} raw / {} welded Vertices",
cx, cy, cz, rawCount, wCount);
}
// ── Pinsel ───────────────────────────────────────────────────────────────
private void applyBrushEdit(SharedInput.SculptEdit edit) {
Camera cam = app.getCamera();
float jmeX = edit.screenX() * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - edit.screenY() * (float) input.viewportScaleY;
Vector3f ori = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f tgt = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(ori, tgt.subtract(ori).normalizeLocal());
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
if (cr.size() == 0) return;
Geometry hitGeo = cr.getClosestCollision().getGeometry();
Vector3f hitWorld = cr.getClosestCollision().getContactPoint();
EditableMesh hit = null;
for (EditableMesh em : meshes.values()) {
if (em.geo == hitGeo) { hit = em; break; }
}
if (hit == null) return;
int mode = input.sculptTool.mode.getSelectedIndex();
if (mode == SculptMeshTool.MODE_SELECT) {
setSelection(hit);
return;
}
long hitKey = chunkKey(hit.cx, hit.cy, hit.cz);
if (!sessionSnapshot.containsKey(hitKey))
sessionSnapshot.put(hitKey, hit.wp.clone());
// Trefpunkt in lokalen Chunk-Raum transformieren
float ox = hit.cx * VoxelChunk.CELLS - 2048f;
float oy = hit.cy * (float) VoxelChunk.CELLS;
float oz = hit.cz * VoxelChunk.CELLS - 2048f;
float lhx = hitWorld.x - ox;
float lhy = hitWorld.y - oy;
float lhz = hitWorld.z - oz;
float radius = (float) input.sculptTool.brushRadius.getValue();
float strength = (float) input.sculptTool.brushStrength.getValue() * 0.5f;
float dirMul = (edit.action() == 1) ? -1f : 1f;
if (mode == SculptMeshTool.MODE_LOWER) dirMul = -dirMul;
boolean changed = switch (mode) {
case SculptMeshTool.MODE_RAISE, SculptMeshTool.MODE_LOWER ->
brushRaiseLower(hit, lhx, lhy, lhz, radius, strength * dirMul);
case SculptMeshTool.MODE_SMOOTH -> brushSmooth(hit, lhx, lhy, lhz, radius, strength);
case SculptMeshTool.MODE_FLATTEN -> brushFlatten(hit, lhx, lhy, lhz, radius, strength);
default -> false;
};
if (changed) {
recomputeNormals(hit.wp, hit.wn, hit.wi, hit.wp.length / 3);
updateMeshBuffers(hit);
hasDirty = true;
dirtyTimer = 0f;
}
}
// Alle Brush-Methoden operieren auf welded Positionen (hit.wp / hit.wn / hit.wnb)
private boolean brushRaiseLower(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp, n = em.wn;
boolean changed = false;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi] - lhx, dy = p[vi+1] - lhy, dz = p[vi+2] - lhz;
float d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= r2) continue;
float falloff = 1f - d2 / r2;
p[vi] += n[vi] * strength * falloff;
p[vi+1] += n[vi+1] * strength * falloff;
p[vi+2] += n[vi+2] * strength * falloff;
changed = true;
}
return changed;
}
private boolean brushSmooth(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp;
float[] smoothed = p.clone();
boolean changed = false;
for (int i = 0, vi = 0; vi < p.length; i++, vi += 3) {
float dx = p[vi] - lhx, dy = p[vi+1] - lhy, dz = p[vi+2] - lhz;
float d2 = dx*dx + dy*dy + dz*dz;
if (d2 >= r2) continue;
int[] nb = em.wnb[i];
if (nb == null || nb.length == 0) continue;
float ax = 0, ay = 0, az = 0;
for (int ni : nb) { ax += p[ni*3]; ay += p[ni*3+1]; az += p[ni*3+2]; }
ax /= nb.length; ay /= nb.length; az /= nb.length;
float falloff = (1f - d2 / r2) * strength;
smoothed[vi] = p[vi] + (ax - p[vi]) * falloff;
smoothed[vi+1] = p[vi+1] + (ay - p[vi+1]) * falloff;
smoothed[vi+2] = p[vi+2] + (az - p[vi+2]) * falloff;
changed = true;
}
if (changed) System.arraycopy(smoothed, 0, p, 0, p.length);
return changed;
}
private boolean brushFlatten(EditableMesh em, float lhx, float lhy, float lhz,
float radius, float strength) {
float r2 = radius * radius;
float[] p = em.wp;
float avgY = 0; int count = 0;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi]-lhx, dy = p[vi+1]-lhy, dz = p[vi+2]-lhz;
if (dx*dx+dy*dy+dz*dz < r2) { avgY += p[vi+1]; count++; }
}
if (count == 0) return false;
avgY /= count;
boolean changed = false;
for (int vi = 0; vi < p.length; vi += 3) {
float dx = p[vi]-lhx, dy = p[vi+1]-lhy, dz = p[vi+2]-lhz;
float d2 = dx*dx+dy*dy+dz*dz;
if (d2 >= r2) continue;
p[vi+1] += (avgY - p[vi+1]) * (1f - d2/r2) * strength;
changed = true;
}
return changed;
}
// ── Normalen & Mesh-Update ────────────────────────────────────────────────
private static void recomputeNormals(float[] positions, float[] normals,
int[] indices, int wCount) {
Arrays.fill(normals, 0, wCount * 3, 0f);
for (int i = 0; i < indices.length; i += 3) {
int i0 = indices[i]*3, i1 = indices[i+1]*3, i2 = indices[i+2]*3;
float e1x = positions[i1] -positions[i0], e1y = positions[i1+1]-positions[i0+1], e1z = positions[i1+2]-positions[i0+2];
float e2x = positions[i2] -positions[i0], e2y = positions[i2+1]-positions[i0+1], e2z = positions[i2+2]-positions[i0+2];
float nx = e1y*e2z - e1z*e2y;
float ny = e1z*e2x - e1x*e2z;
float nz = e1x*e2y - e1y*e2x;
normals[i0]+=nx; normals[i0+1]+=ny; normals[i0+2]+=nz;
normals[i1]+=nx; normals[i1+1]+=ny; normals[i1+2]+=nz;
normals[i2]+=nx; normals[i2+1]+=ny; normals[i2+2]+=nz;
}
for (int i = 0; i < wCount * 3; i += 3) {
float len = (float)Math.sqrt(normals[i]*normals[i]+normals[i+1]*normals[i+1]+normals[i+2]*normals[i+2]);
if (len > 1e-4f) { normals[i]/=len; normals[i+1]/=len; normals[i+2]/=len; }
}
}
/** Fächert welded Positions/Normals auf rawCount auf und lädt in GPU-Buffers. */
private static void updateMeshBuffers(EditableMesh em) {
// welded → roh aufächern
for (int v = 0; v < em.rawCount; v++) {
int w = em.v2w[v];
em.rp[v*3] = em.wp[w*3];
em.rp[v*3+1] = em.wp[w*3+1];
em.rp[v*3+2] = em.wp[w*3+2];
em.rn[v*3] = em.wn[w*3];
em.rn[v*3+1] = em.wn[w*3+1];
em.rn[v*3+2] = em.wn[w*3+2];
}
com.jme3.scene.Mesh m = em.geo.getMesh();
FloatBuffer pb = (FloatBuffer) m.getBuffer(VertexBuffer.Type.Position).getData();
pb.clear(); pb.put(em.rp); pb.rewind();
m.getBuffer(VertexBuffer.Type.Position).setUpdateNeeded();
VertexBuffer nb = m.getBuffer(VertexBuffer.Type.Normal);
if (nb != null) {
FloatBuffer nbf = (FloatBuffer) nb.getData();
nbf.clear(); nbf.put(em.rn); nbf.rewind();
nb.setUpdateNeeded();
}
m.updateBound();
}
/** Wie updateMeshBuffers, aber schreibt auch in das existierende JME3-Mesh-Objekt (initiales Laden). */
private static void expandAndUpload(EditableMesh em, com.jme3.scene.Mesh m) {
updateMeshBuffers(em);
}
private static int[][] buildNeighbors(int wCount, int[] indices) {
@SuppressWarnings("unchecked")
Set<Integer>[] sets = new Set[wCount];
for (int i = 0; i < wCount; i++) sets[i] = new HashSet<>();
for (int i = 0; i < indices.length; i += 3) {
int a = indices[i], b = indices[i+1], c = indices[i+2];
if (a == b || b == c || a == c) continue; // degeneriertes Dreieck
sets[a].add(b); sets[a].add(c);
sets[b].add(a); sets[b].add(c);
sets[c].add(a); sets[c].add(b);
}
int[][] result = new int[wCount][];
for (int i = 0; i < wCount; i++)
result[i] = sets[i].stream().mapToInt(Integer::intValue).toArray();
return result;
}
/** Quantisierter Positionsschlüssel für das Vertex-Welding (1/2048 Voxel Präzision). */
private static long posKey(float[] pos, int vi) {
long qx = Math.round(pos[vi*3] * 2048f) & 0x3FFFFL;
long qy = Math.round(pos[vi*3+1] * 2048f) & 0x3FFFFL;
long qz = Math.round(pos[vi*3+2] * 2048f) & 0x3FFFFL;
return qx | (qy << 18) | (qz << 36);
}
// ── Selektion ────────────────────────────────────────────────────────────
private void setSelection(EditableMesh em) {
long newKey = (em != null) ? chunkKey(em.cx, em.cy, em.cz) : -1L;
if (newKey == selectedKey) return;
if (selectedKey != -1L) {
EditableMesh prev = meshes.get(selectedKey);
if (prev != null && prev.geo != null && normalMat != null)
prev.geo.setMaterial(normalMat);
}
selectedKey = newKey;
input.selectedSculptKey = newKey;
if (em != null) {
em.geo.setMaterial(highlightMat);
input.selectedSculptLabel = "Chunk (" + em.cx + ", " + em.cy + ", " + em.cz + ")";
} else {
input.selectedSculptLabel = null;
}
}
private void applyTranslate(long key, float dx, float dy, float dz) {
EditableMesh em = meshes.get(key);
if (em == null) return;
float[] p = em.wp;
for (int i = 0; i < p.length; i += 3) {
p[i] += dx;
p[i+1] += dy;
p[i+2] += dz;
}
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
hasDirty = true;
dirtyTimer = 0f;
}
private void applyRotateY(long key, float degrees) {
EditableMesh em = meshes.get(key);
if (em == null) return;
float[] p = em.wp;
float rad = degrees * FastMath.DEG_TO_RAD;
float cos = FastMath.cos(rad);
float sin = FastMath.sin(rad);
int n = p.length / 3;
float cx = 0, cz = 0;
for (int i = 0; i < p.length; i += 3) { cx += p[i]; cz += p[i+2]; }
cx /= n; cz /= n;
for (int i = 0; i < p.length; i += 3) {
float lx = p[i] - cx;
float lz = p[i+2] - cz;
p[i] = cos * lx - sin * lz + cx;
p[i+2] = sin * lx + cos * lz + cz;
}
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
hasDirty = true;
dirtyTimer = 0f;
}
private void deleteSelected() {
EditableMesh em = meshes.get(selectedKey);
if (em == null) return;
em.node.removeFromParent();
meshes.remove(selectedKey);
if (SculptedMeshIO.exists(em.cx, em.cy, em.cz)) {
try { SculptedMeshIO.delete(em.cx, em.cy, em.cz); }
catch (Exception e) { log.warn("Sculpt-Datei löschen fehlgeschlagen: {}", e.getMessage()); }
}
for (int lod = 0; lod < 3; lod++) {
try { java.nio.file.Files.deleteIfExists(VoxelChunkIO.getBakedPath(em.cx, em.cy, em.cz, lod)); }
catch (Exception e) { log.warn("Baked LOD{} löschen fehlgeschlagen: {}", lod, e.getMessage()); }
}
selectedKey = -1L;
input.selectedSculptKey = -1L;
input.selectedSculptLabel = null;
undoStack.clear();
redoStack.clear();
}
// ── Undo / Redo ──────────────────────────────────────────────────────────
private void doUndo() {
if (undoStack.isEmpty()) return;
Map<Long, float[]> snap = undoStack.pollLast();
Map<Long, float[]> redoSnap = new HashMap<>();
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em != null) redoSnap.put(e.getKey(), em.wp.clone());
}
if (redoStack.size() >= MAX_UNDO) redoStack.pollFirst();
redoStack.addLast(redoSnap);
applySnapshot(snap);
}
private void doRedo() {
if (redoStack.isEmpty()) return;
Map<Long, float[]> snap = redoStack.pollLast();
Map<Long, float[]> undoSnap = new HashMap<>();
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em != null) undoSnap.put(e.getKey(), em.wp.clone());
}
if (undoStack.size() >= MAX_UNDO) undoStack.pollFirst();
undoStack.addLast(undoSnap);
applySnapshot(snap);
}
private void applySnapshot(Map<Long, float[]> snap) {
for (Map.Entry<Long, float[]> e : snap.entrySet()) {
EditableMesh em = meshes.get(e.getKey());
if (em == null) continue;
System.arraycopy(e.getValue(), 0, em.wp, 0, em.wp.length);
recomputeNormals(em.wp, em.wn, em.wi, em.wp.length / 3);
updateMeshBuffers(em);
}
hasDirty = true;
dirtyTimer = 0f;
}
// ── Speichern ────────────────────────────────────────────────────────────
private void saveAllDirty() {
for (EditableMesh em : meshes.values()) {
try {
// Welded Positionen speichern — Overlay enthält immer welded Daten
SculptedMeshIO.save(new SculptedMesh(em.cx, em.cy, em.cz, em.wp));
} catch (IOException e) {
log.error("Sculpt-Chunk speichern fehlgeschlagen ({},{},{}): {}",
em.cx, em.cy, em.cz, e.getMessage());
}
}
}
// ── Pinsel-Indikator ─────────────────────────────────────────────────────
private Geometry buildBrushIndicator() {
int segments = 32;
FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3);
pos.put(0f).put(0f).put(0f);
for (int i = 0; i < segments; i++) {
float a = FastMath.TWO_PI * i / segments;
pos.put(FastMath.cos(a)).put(0f).put(FastMath.sin(a));
}
java.nio.IntBuffer idx = BufferUtils.createIntBuffer(segments * 3);
for (int i = 0; i < segments; i++) {
idx.put(0);
idx.put(1 + i);
idx.put(1 + (i + 1) % segments);
}
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("sculptBrushIndicator", mesh);
Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.5f, 0.1f, 0.45f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
private void updateBrushIndicator() {
if (brushIndicator == null) return;
boolean showBrush = active
&& input.sculptTool.mode.getSelectedIndex() != SculptMeshTool.MODE_SELECT;
if (!showBrush) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float mx = input.mouseScreenX;
float my = input.mouseScreenY;
if (mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float jmeX = mx * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Vector3f ori = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f tgt = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(ori, tgt.subtract(ori).normalizeLocal());
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
if (cr.size() == 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
Vector3f hitPos = cr.getClosestCollision().getContactPoint();
Vector3f hitNormal = cr.getClosestCollision().getContactNormal();
if (hitNormal == null || hitNormal.lengthSquared() < 1e-6f) hitNormal = Vector3f.UNIT_Y;
float r = (float) input.sculptTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(hitPos.add(hitNormal.mult(0.05f)));
brushIndicator.setLocalScale(r, 1f, r);
Vector3f axis = Vector3f.UNIT_Y.cross(hitNormal);
Quaternion rot = new Quaternion();
if (axis.lengthSquared() < 1e-6f) {
rot.fromAngleNormalAxis(
Vector3f.UNIT_Y.dot(hitNormal) > 0 ? 0f : FastMath.PI,
Vector3f.UNIT_X);
} else {
float angle = FastMath.acos(FastMath.clamp(Vector3f.UNIT_Y.dot(hitNormal), -1f, 1f));
rot.fromAngleNormalAxis(angle, axis.normalizeLocal());
}
brushIndicator.setLocalRotation(rot);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
}

View File

@@ -0,0 +1,584 @@
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.VertexBuffer.Type;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.util.BufferUtils;
import de.blight.common.PlacedStone;
import de.blight.common.PlacedStoneIO;
import de.blight.editor.SharedInput;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.FloatBuffer;
import java.util.*;
/**
* Rendert und verwaltet prozedural generierte Steine im Editor.
*
* Chunk-Schema: 128 m × 128 m, identisch mit GrassVertexState.
* LOD:
* - LOD0 (Icosphere subdiv 2, 320 Dreiecke): immer
* - LOD1 (Icosphere subdiv 1, 80 Dreiecke): nur für Steine mit Durchmesser > 1 m
* - LOD2: niemals
* LOD-Wechsel: hängt an Kamera-Distanz zum Chunk (< LOD1_DIST → LOD0, sonst → LOD1, > CULL_DIST → ausgeblendet).
*/
public class StoneEditorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(StoneEditorState.class);
// ── Chunk-Konstanten (deckungsgleich mit GrassVertexState) ────────────────
private static final int TERRAIN_HALF = 2048;
private static final int CHUNK_SIZE = 128;
private static final int CHUNKS_PER_AXIS = (TERRAIN_HALF * 2) / CHUNK_SIZE; // 32
private static final int CHUNK_COUNT = CHUNKS_PER_AXIS * CHUNKS_PER_AXIS; // 1024
private static final int MAX_REBUILDS = 2;
// ── LOD-Distanzen ─────────────────────────────────────────────────────────
private static final float LOD1_DIST = 60f; // ab hier LOD1 für große Steine
private static final float CULL_DIST = 200f; // weiter weg: Chunk unsichtbar
// ── Felder ────────────────────────────────────────────────────────────────
private final SharedInput input;
private AssetManager assetManager;
private Camera cam;
private TerrainQuad terrain;
private Node rootNode;
private Node stoneRoot;
@SuppressWarnings("unchecked")
private final List<PlacedStone>[] chunkStones = new List[CHUNK_COUNT];
/** Pro Chunk: Node mit LOD0-Geometrien. */
private final Node[] lod0Nodes = new Node[CHUNK_COUNT];
/** Pro Chunk: Node mit LOD1-Geometrien (nur Steine ≥ 1m Durchmesser). */
private final Node[] lod1Nodes = new Node[CHUNK_COUNT];
private final boolean[] dirtyChunks = new boolean[CHUNK_COUNT];
private final Material[] slotMat = new Material[PlacedStoneIO.SLOT_COUNT];
private Material defaultMat;
private Geometry brushIndicator;
private final Random random = new Random();
private boolean modified = false;
public StoneEditorState(SharedInput input) {
this.input = input;
for (int i = 0; i < CHUNK_COUNT; i++) chunkStones[i] = new ArrayList<>();
}
public void setTerrain(TerrainQuad terrain) { this.terrain = terrain; }
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
protected void initialize(Application app) {
assetManager = app.getAssetManager();
cam = app.getCamera();
rootNode = ((SimpleApplication) app).getRootNode();
stoneRoot = new Node("stoneRoot");
rootNode.attachChild(stoneRoot);
reloadMaterials();
loadFromDisk();
brushIndicator = buildBrushIndicator();
rootNode.attachChild(brushIndicator);
}
@Override
protected void cleanup(Application app) {
saveIfModified();
rootNode.detachChild(stoneRoot);
rootNode.detachChild(brushIndicator);
}
@Override protected void onEnable() { stoneRoot.setCullHint(Spatial.CullHint.Inherit); }
@Override protected void onDisable() { stoneRoot.setCullHint(Spatial.CullHint.Always); }
// ── Update ────────────────────────────────────────────────────────────────
@Override
public void update(float tpf) {
if (input.stoneTexturesChanged) {
input.stoneTexturesChanged = false;
reloadMaterials();
Arrays.fill(dirtyChunks, true);
}
updateBrushIndicator();
processEdits();
processTerrainUpdates();
rebuildDirtyChunks();
updateChunkLOD();
}
// ── Edit-Verarbeitung ─────────────────────────────────────────────────────
private void processTerrainUpdates() {
float[] area;
while ((area = input.terrainEditedAreas.poll()) != null) {
float wx = area[0], wz = area[1], radius = area[2];
float checkR = radius + CHUNK_SIZE;
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
Vector2f center = chunkCenter(ci);
float dx = center.x - wx, dz = center.y - wz;
if (dx*dx + dz*dz <= checkR * checkR) dirtyChunks[ci] = true;
}
}
}
private void processEdits() {
SharedInput.StoneEdit edit;
while ((edit = input.stoneEditQueue.poll()) != null) {
float jx = edit.screenX() * (float) input.viewportScaleX;
float jy = cam.getHeight() - edit.screenY() * (float) input.viewportScaleY;
Vector3f hit = raycastTerrain(jx, jy);
if (hit == null) continue;
if (edit.action() > 0) addStonesAt(hit.x, hit.z);
else removeStonesAt(hit.x, hit.z);
}
}
private void addStonesAt(float wx, float wz) {
if (terrain == null) return;
double brushR = input.stoneTool.brushRadius.getValue();
double minR = input.stoneTool.minSize.getValue() / 2.0;
double maxR = input.stoneTool.maxSize.getValue() / 2.0;
int count = (int) input.stoneTool.density.getValue();
String[] paths = input.stoneTool.texturePaths;
// Anzahl belegter Slots ermitteln (nur existierende Texturen zählen)
int activeSlots = 0;
for (String p : paths) if (p != null && !p.isEmpty()) activeSlots++;
if (activeSlots == 0) activeSlots = 1; // Slot 0 = Default-Grau
for (int i = 0; i < count; i++) {
double angle = random.nextDouble() * Math.PI * 2;
double dist = Math.sqrt(random.nextDouble()) * brushR;
float sx = wx + (float)(Math.cos(angle) * dist);
float sz = wz + (float)(Math.sin(angle) * dist);
float th = terrain.getHeight(new Vector2f(sx, sz));
if (!Float.isFinite(th)) continue;
float radius = (float)(minR + random.nextDouble() * (maxR - minR));
float rotY = random.nextFloat() * 360f;
float sinkFrac = 0.2f + random.nextFloat() * 0.3f;
int slot = random.nextInt(activeSlots);
int seed = random.nextInt();
PlacedStone stone = new PlacedStone(sx, sz, radius, rotY, slot, sinkFrac, seed);
int ci = chunkIndex(sx, sz);
if (ci >= 0) {
chunkStones[ci].add(stone);
dirtyChunks[ci] = true;
modified = true;
}
}
}
private void removeStonesAt(float wx, float wz) {
double brushR = input.stoneTool.brushRadius.getValue();
float r2 = (float)(brushR * brushR);
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
Vector2f center = chunkCenter(ci);
float cdx = center.x - wx, cdz = center.y - wz;
if (cdx*cdx + cdz*cdz > (brushR + CHUNK_SIZE) * (brushR + CHUNK_SIZE)) continue;
boolean removed = chunkStones[ci].removeIf(s -> {
float dx = s.x() - wx, dz = s.z() - wz;
return dx*dx + dz*dz <= r2;
});
if (removed) { dirtyChunks[ci] = true; modified = true; }
}
}
// ── Chunk-Geometrie ───────────────────────────────────────────────────────
private void rebuildDirtyChunks() {
int rebuilt = 0;
for (int ci = 0; ci < CHUNK_COUNT && rebuilt < MAX_REBUILDS; ci++) {
if (!dirtyChunks[ci]) continue;
dirtyChunks[ci] = false;
rebuildChunk(ci);
rebuilt++;
}
}
private void rebuildChunk(int ci) {
// Alte Nodes entfernen
if (lod0Nodes[ci] != null) { lod0Nodes[ci].detachAllChildren(); stoneRoot.detachChild(lod0Nodes[ci]); }
if (lod1Nodes[ci] != null) { lod1Nodes[ci].detachAllChildren(); stoneRoot.detachChild(lod1Nodes[ci]); }
List<PlacedStone> stones = chunkStones[ci];
if (stones.isEmpty()) { lod0Nodes[ci] = null; lod1Nodes[ci] = null; return; }
Node n0 = new Node("stone_lod0_" + ci);
Node n1 = new Node("stone_lod1_" + ci);
for (PlacedStone s : stones) {
float y = stoneWorldY(s);
Geometry g0 = buildStoneGeom(s, y, 2); // LOD0: 2 Subdiv
n0.attachChild(g0);
if (s.radius() * 2f > 1f) { // Durchmesser > 1m → LOD1
Geometry g1 = buildStoneGeom(s, y, 1); // LOD1: 1 Subdiv
n1.attachChild(g1);
}
}
stoneRoot.attachChild(n0);
stoneRoot.attachChild(n1);
lod0Nodes[ci] = n0;
lod1Nodes[ci] = n1;
}
private Geometry buildStoneGeom(PlacedStone s, float worldY, int subdivisions) {
Mesh mesh = buildStoneMesh(s.radius(), s.noiseSeed(), subdivisions);
// Einsinken: Zentrum liegt bei terrain - sinkFraction * radius * 2 + radius (= terrain + radius*(1 - 2*sinkFraction))
float yCenter = worldY + s.radius() * (1f - 2f * s.sinkFraction());
Geometry g = new Geometry("stone", mesh);
g.setMaterial(materialForSlot(s.textureSlot()));
g.setLocalTranslation(s.x(), yCenter, s.z());
g.rotate(0f, s.rotY() * FastMath.DEG_TO_RAD, 0f);
return g;
}
// ── LOD-Umschaltung ───────────────────────────────────────────────────────
private void updateChunkLOD() {
Vector3f camPos = cam.getLocation();
for (int ci = 0; ci < CHUNK_COUNT; ci++) {
if (lod0Nodes[ci] == null) continue;
Vector2f center = chunkCenter(ci);
float dx = camPos.x - center.x, dz = camPos.z - center.y;
float dist = (float) Math.sqrt(dx*dx + dz*dz);
if (dist > CULL_DIST) {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Always);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Always);
} else if (dist > LOD1_DIST) {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Always);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Inherit);
} else {
lod0Nodes[ci].setCullHint(Spatial.CullHint.Inherit);
if (lod1Nodes[ci] != null) lod1Nodes[ci].setCullHint(Spatial.CullHint.Always);
}
}
}
// ── Mesh-Generierung: Icosphere ───────────────────────────────────────────
private static final float PHI = (1f + (float) Math.sqrt(5)) / 2f;
/** Basisvertices des Ikosaeders (auf Einheitskugel normiert). */
private static final float[][] ICO_V = normalize12(new float[][]{
{-1, PHI, 0}, { 1, PHI, 0}, {-1, -PHI, 0}, { 1, -PHI, 0},
{ 0, -1, PHI}, { 0, 1, PHI}, { 0, -1, -PHI}, { 0, 1, -PHI},
{ PHI, 0, -1}, { PHI, 0, 1}, {-PHI, 0, -1}, {-PHI, 0, 1}
});
/** 20 Dreiecke des Ikosaeders. */
private static final int[][] ICO_F = {
{0,11,5},{0,5,1},{0,1,7},{0,7,10},{0,10,11},
{1,5,9},{5,11,4},{11,10,2},{10,7,6},{7,1,8},
{3,9,4},{3,4,2},{3,2,6},{3,6,8},{3,8,9},
{4,9,5},{2,4,11},{6,2,10},{8,6,7},{9,8,1}
};
private static float[][] normalize12(float[][] raw) {
float[][] r = new float[raw.length][3];
for (int i = 0; i < raw.length; i++) r[i] = normalizeV(raw[i]);
return r;
}
private static float[] normalizeV(float[] v) {
float len = (float) Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
return new float[]{v[0]/len, v[1]/len, v[2]/len};
}
/**
* Erzeugt einen Stein-Mesh mit der gegebenen Anzahl an Icosphere-Unterteilungen.
* Noise-Verformung wird durch noiseSeed deterministisch gesteuert.
*/
private static Mesh buildStoneMesh(float radius, int noiseSeed, int subdivisions) {
// 1. Icosphere aufbauen und unterteilen
List<float[]> verts = new ArrayList<>(Arrays.asList(ICO_V));
List<int[]> faces = new ArrayList<>(Arrays.asList(ICO_F));
for (int s = 0; s < subdivisions; s++) {
List<float[]> nv = new ArrayList<>(verts);
List<int[]> nf = new ArrayList<>();
Map<Long, Integer> midCache = new HashMap<>();
for (int[] f : faces) {
int a = f[0], b = f[1], c = f[2];
int ab = getMid(nv, midCache, a, b);
int bc = getMid(nv, midCache, b, c);
int ca = getMid(nv, midCache, c, a);
nf.add(new int[]{a, ab, ca});
nf.add(new int[]{b, bc, ab});
nf.add(new int[]{c, ca, bc});
nf.add(new int[]{ab, bc, ca});
}
verts = nv; faces = nf;
}
// 2. Noise-Verformung (entlang Vertex-Normal = Einheitskugel-Richtung)
Random rng = new Random(noiseSeed);
float ox = rng.nextFloat() * 50f, oy = rng.nextFloat() * 50f, oz = rng.nextFloat() * 50f;
float freq = 2.5f + (Math.abs(noiseSeed) % 3); // 2.54.5
float amp = 0.22f; // ±22 % Verformung
int nv = verts.size();
float[] pos = new float[nv * 3];
float[] nor = new float[nv * 3];
float[] uv = new float[nv * 2];
for (int i = 0; i < nv; i++) {
float[] unit = verts.get(i);
float n = smoothNoise3D(unit[0] * freq + ox, unit[1] * freq + oy, unit[2] * freq + oz);
float r = radius * (1f + n * amp);
pos[i*3] = unit[0] * r;
pos[i*3+1] = unit[1] * r;
pos[i*3+2] = unit[2] * r;
// UV: sphärisch
uv[i*2] = (float)(Math.atan2(unit[2], unit[0]) / (2 * Math.PI) + 0.5);
uv[i*2+1] = (float)(Math.asin(Math.max(-1, Math.min(1, unit[1]))) / Math.PI + 0.5);
}
// 3. Vertex-Normalen aus Dreiecken akkumulieren
for (int[] f : faces) {
int ai = f[0]*3, bi = f[1]*3, ci = f[2]*3;
float ax = pos[ai], ay = pos[ai+1], az = pos[ai+2];
float bx = pos[bi], by = pos[bi+1], bz = pos[bi+2];
float cx2= pos[ci], cy = pos[ci+1], cz = pos[ci+2];
float nx = (by-ay)*(cz-az) - (bz-az)*(cy-ay);
float ny = (bz-az)*(cx2-ax) - (bx-ax)*(cz-az);
float nz = (bx-ax)*(cy-ay) - (by-ay)*(cx2-ax);
for (int vi : f) { nor[vi*3] += nx; nor[vi*3+1] += ny; nor[vi*3+2] += nz; }
}
for (int i = 0; i < nv; i++) {
float len = (float) Math.sqrt(nor[i*3]*nor[i*3] + nor[i*3+1]*nor[i*3+1] + nor[i*3+2]*nor[i*3+2]);
if (len > 0) { nor[i*3] /= len; nor[i*3+1] /= len; nor[i*3+2] /= len; }
}
// 4. JME3-Mesh zusammenbauen
FloatBuffer pb = BufferUtils.createFloatBuffer(pos);
FloatBuffer nb = BufferUtils.createFloatBuffer(nor);
FloatBuffer ub = BufferUtils.createFloatBuffer(uv);
java.nio.IntBuffer ib = BufferUtils.createIntBuffer(faces.size() * 3);
for (int[] f : faces) ib.put(f[0]).put(f[1]).put(f[2]);
ib.flip();
Mesh mesh = new Mesh();
mesh.setBuffer(Type.Position, 3, pb);
mesh.setBuffer(Type.Normal, 3, nb);
mesh.setBuffer(Type.TexCoord, 2, ub);
mesh.setBuffer(Type.Index, 3, ib);
mesh.updateBound();
return mesh;
}
private static int getMid(List<float[]> verts, Map<Long, Integer> cache, int a, int b) {
long key = a < b ? ((long)a << 32 | b) : ((long)b << 32 | a);
return cache.computeIfAbsent(key, k -> {
float[] va = verts.get(a), vb = verts.get(b);
verts.add(normalizeV(new float[]{(va[0]+vb[0])*.5f, (va[1]+vb[1])*.5f, (va[2]+vb[2])*.5f}));
return verts.size() - 1;
});
}
// ── 3D-Rauschen ──────────────────────────────────────────────────────────
private static float valueNoise3D(int x, int y, int z) {
int h = x * 1619 ^ y * 31337 ^ z * 6971 ^ (x * y * z * 1013);
h = h ^ (h >>> 13);
h = h * (h * h * 15731 + 789221) + 1376312589;
return (h & 0x7fffffff) * (1f / 2147483647f);
}
private static float smoothNoise3D(float x, float y, float z) {
int xi = (int) Math.floor(x), yi = (int) Math.floor(y), zi = (int) Math.floor(z);
float fx = x-xi, fy = y-yi, fz = z-zi;
float c000 = valueNoise3D(xi, yi, zi), c100 = valueNoise3D(xi+1, yi, zi);
float c010 = valueNoise3D(xi, yi+1, zi), c110 = valueNoise3D(xi+1, yi+1, zi);
float c001 = valueNoise3D(xi, yi, zi+1), c101 = valueNoise3D(xi+1, yi, zi+1);
float c011 = valueNoise3D(xi, yi+1, zi+1), c111 = valueNoise3D(xi+1, yi+1, zi+1);
float lo = lerp(lerp(c000,c100,fx), lerp(c010,c110,fx), fy);
float hi = lerp(lerp(c001,c101,fx), lerp(c011,c111,fx), fy);
return lerp(lo, hi, fz) * 2f - 1f; // [-1, 1]
}
private static float lerp(float a, float b, float t) { return a + (b-a)*t; }
// ── Materialien ───────────────────────────────────────────────────────────
private void reloadMaterials() {
defaultMat = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
defaultMat.setColor("Diffuse", new ColorRGBA(0.55f, 0.52f, 0.48f, 1f));
defaultMat.setColor("Ambient", new ColorRGBA(0.15f, 0.14f, 0.13f, 1f));
defaultMat.setColor("Specular", ColorRGBA.Black);
defaultMat.setBoolean("UseMaterialColors", true);
String[] paths = input.stoneTool.texturePaths;
for (int i = 0; i < PlacedStoneIO.SLOT_COUNT; i++) {
String p = (i < paths.length && paths[i] != null) ? paths[i] : "";
if (!p.isEmpty()) {
try {
Material m = new Material(assetManager, "Common/MatDefs/Light/Lighting.j3md");
m.setTexture("DiffuseMap", assetManager.loadTexture(p));
m.setColor("Diffuse", ColorRGBA.White);
m.setColor("Ambient", new ColorRGBA(0.2f, 0.2f, 0.2f, 1f));
m.setColor("Specular", ColorRGBA.Black);
m.setBoolean("UseMaterialColors", true);
slotMat[i] = m;
} catch (Exception e) {
log.warn("[StoneEditorState] Textur nicht ladbar: {}", p);
slotMat[i] = null;
}
} else {
slotMat[i] = null;
}
}
}
private Material materialForSlot(int slot) {
if (slot >= 0 && slot < slotMat.length && slotMat[slot] != null) return slotMat[slot];
return defaultMat;
}
// ── Brush-Indicator ───────────────────────────────────────────────────────
private void updateBrushIndicator() {
if (input.activeLayer != SharedInput.LAYER_STONE || input.mouseScreenX < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float jx = input.mouseScreenX * (float) input.viewportScaleX;
float jy = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY;
Vector3f hit = raycastTerrain(jx, jy);
if (hit != null) {
float r = (float) input.stoneTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(hit.x, hit.y + 0.15f, hit.z);
brushIndicator.setLocalScale(r, 1f, r);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
} else {
brushIndicator.setCullHint(Spatial.CullHint.Always);
}
}
private Geometry buildBrushIndicator() {
int segments = 48;
java.nio.FloatBuffer pos = BufferUtils.createFloatBuffer((segments + 1) * 3);
pos.put(0f).put(0f).put(0f);
for (int i = 0; i < segments; i++) {
float a = FastMath.TWO_PI * i / segments;
pos.put(FastMath.cos(a)).put(0f).put(FastMath.sin(a));
}
java.nio.IntBuffer idx = BufferUtils.createIntBuffer(segments * 3);
for (int i = 0; i < segments; i++) {
idx.put(0);
idx.put(1 + i);
idx.put(1 + (i + 1) % segments);
}
Mesh mesh = new Mesh();
mesh.setBuffer(Type.Position, 3, pos);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idx);
mesh.updateBound();
Geometry geo = new Geometry("stoneBrushIndicator", mesh);
Material mat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.9f, 0.5f, 0.1f, 0.4f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Raycast ───────────────────────────────────────────────────────────────
private Vector3f raycastTerrain(float sx, float sy) {
if (terrain == null) return null;
Ray ray = new Ray(cam.getWorldCoordinates(new Vector2f(sx, sy), 0f),
cam.getWorldCoordinates(new Vector2f(sx, sy), 1f));
ray.getDirection().subtractLocal(ray.getOrigin()).normalizeLocal();
CollisionResults res = new CollisionResults();
terrain.collideWith(ray, res);
return res.size() > 0 ? res.getClosestCollision().getContactPoint() : null;
}
// ── Hilfsmethoden ────────────────────────────────────────────────────────
private int chunkIndex(float wx, float wz) {
int cx = (int)((wx + TERRAIN_HALF) / CHUNK_SIZE);
int cz = (int)((wz + TERRAIN_HALF) / CHUNK_SIZE);
if (cx < 0 || cx >= CHUNKS_PER_AXIS || cz < 0 || cz >= CHUNKS_PER_AXIS) return -1;
return cz * CHUNKS_PER_AXIS + cx;
}
private Vector2f chunkCenter(int ci) {
int cx = ci % CHUNKS_PER_AXIS;
int cz = ci / CHUNKS_PER_AXIS;
float wx = cx * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
float wz = cz * CHUNK_SIZE - TERRAIN_HALF + CHUNK_SIZE * 0.5f;
return new Vector2f(wx, wz);
}
private float stoneWorldY(PlacedStone s) {
if (terrain == null) return 0f;
float h = terrain.getHeight(new Vector2f(s.x(), s.z()));
if (!Float.isFinite(h)) return 0f;
return h < -1e10f ? 0f : h;
}
// ── Persistenz ───────────────────────────────────────────────────────────
private void loadFromDisk() {
try {
PlacedStoneIO.StoneData data = PlacedStoneIO.load();
if (data == null) return;
// Texturpfade in StoneTool übernehmen
String[] paths = data.slotPaths();
if (paths != null) {
for (int i = 0; i < Math.min(paths.length, input.stoneTool.texturePaths.length); i++)
input.stoneTool.texturePaths[i] = paths[i] != null ? paths[i] : "";
reloadMaterials();
}
for (PlacedStone s : data.stones()) {
int ci = chunkIndex(s.x(), s.z());
if (ci >= 0) { chunkStones[ci].add(s); dirtyChunks[ci] = true; }
}
log.info("[StoneEditorState] {} Steine geladen.", data.stones().size());
} catch (Exception e) {
log.warn("[StoneEditorState] Laden fehlgeschlagen: {}", e.getMessage());
}
}
public void saveIfModified() {
if (!modified) return;
try {
List<PlacedStone> all = new ArrayList<>();
for (List<PlacedStone> list : chunkStones) all.addAll(list);
PlacedStoneIO.save(new PlacedStoneIO.StoneData(input.stoneTool.texturePaths, all));
modified = false;
log.info("[StoneEditorState] {} Steine gespeichert.", all.size());
} catch (Exception e) {
log.error("[StoneEditorState] Speichern fehlgeschlagen: {}", e.getMessage());
}
}
}

View File

@@ -46,6 +46,8 @@ import de.blight.common.MapData;
import de.blight.common.MapIO;
import de.blight.common.PlacedModelIO;
import de.blight.editor.SharedInput;
import de.blight.editor.state.PathNetworkEditorState;
import de.blight.editor.state.RoutineMapState;
import de.blight.editor.tool.HeightTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -81,6 +83,7 @@ public class TerrainEditorState extends BaseAppState {
// ── Kamera ────────────────────────────────────────────────────────────────
private static final float CAM_SPEED = 300f;
private static final float MAX_CAM_Y = 1500f;
private static final float ORBIT_SPEED = 1.5f;
private static final float MOUSE_SENS = 0.003f;
@@ -96,6 +99,7 @@ public class TerrainEditorState extends BaseAppState {
private Geometry brushIndicator;
private PlacedObjectState placedObjectState;
private GrassVertexState grassVertexState;
private StoneEditorState stoneEditorState;
private SceneObjectState sceneObjState;
private ItemPlacementState itemPlacementState;
private LightState lightState;
@@ -238,6 +242,11 @@ public class TerrainEditorState extends BaseAppState {
grassVertexState.setTerrain(terrain);
app.getStateManager().attach(grassVertexState);
input.loadingStatus = "Lade Steine...";
stoneEditorState = new StoneEditorState(input);
stoneEditorState.setTerrain(terrain);
app.getStateManager().attach(stoneEditorState);
sceneObjState = app.getStateManager().getState(SceneObjectState.class);
if (sceneObjState != null) {
sceneObjState.setTerrain(terrain);
@@ -338,6 +347,12 @@ public class TerrainEditorState extends BaseAppState {
PlayToolState playToolState = app.getStateManager().getState(PlayToolState.class);
if (playToolState != null) playToolState.setTerrain(terrain);
RoutineMapState routineMapState = app.getStateManager().getState(RoutineMapState.class);
if (routineMapState != null) routineMapState.setTerrain(terrain);
PathNetworkEditorState pathNetState = app.getStateManager().getState(PathNetworkEditorState.class);
if (pathNetState != null) pathNetState.setTerrain(terrain);
rootNode.attachChild(buildWater());
rootNode.attachChild(buildGrid());
@@ -1143,6 +1158,7 @@ public class TerrainEditorState extends BaseAppState {
// ── Platzierte Objekte synchron speichern (kleine Textdateien) ──────────
// Muss synchron im JME-Thread erfolgen, damit kein Race mit asynchronen
// Löschoperationen aus dem JavaFX-Thread entsteht.
if (sceneObjState != null) sceneObjState.syncInteractables();
try { if (models != null) PlacedModelIO.save(models); } catch (IOException e) { log.error("Modelle speichern", e); }
try { if (lights != null) LightIO.save(lights); } catch (IOException e) { log.error("Lichter speichern", e); }
try { if (emitters != null) EmitterIO.save(emitters); } catch (IOException e) { log.error("Emitter speichern", e); }
@@ -1151,6 +1167,7 @@ public class TerrainEditorState extends BaseAppState {
try { if (soundAreas != null) SoundAreaIO.save(soundAreas); } catch (IOException e) { log.error("Soundbereiche speichern", e); }
try { if (areas != null) AreaIO.save(areas); } catch (IOException e) { log.error("Bereiche speichern", e); }
try { if (locationZones != null) LocationZoneIO.save(locationZones); } catch (IOException e) { log.error("Zonen speichern", e); }
if (stoneEditorState != null) stoneEditorState.saveIfModified();
// ── Schwere Arbeit (Terrain-Upsample + Datei-I/O) auf Hintergrund-Thread ─
saveExecutor.submit(() -> {
@@ -1240,7 +1257,10 @@ public class TerrainEditorState extends BaseAppState {
|| layer == SharedInput.LAYER_SOUND_AREAS || layer == SharedInput.LAYER_AREAS
|| layer == SharedInput.LAYER_LOCATION_ZONES
|| layer == SharedInput.LAYER_PLAY_TOOL
|| layer == SharedInput.LAYER_VOXEL || mx < 0) {
|| layer == SharedInput.LAYER_VOXEL || layer == SharedInput.LAYER_STONE
|| layer == SharedInput.LAYER_BED_LIEGE
|| layer == SharedInput.LAYER_BENCH_SITZ
|| mx < 0) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
@@ -1347,6 +1367,10 @@ public class TerrainEditorState extends BaseAppState {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode);
}
if (terrainChanged) {
float br = (float) input.heightTool.brushRadius.getValue();
input.terrainEditedAreas.offer(new float[]{contact.x, contact.z, br});
}
}
if (processed > 0) terrain.updateModelBound();
}
@@ -1517,7 +1541,9 @@ public class TerrainEditorState extends BaseAppState {
private float terrainDistBelow() {
if (terrain == null) return CAM_SPEED;
Float h = terrain.getHeight(new Vector2f(camPos.x, camPos.z));
return h != null ? Math.max(1f, camPos.y - h) : CAM_SPEED;
if (h == null || !Float.isFinite(h)) return CAM_SPEED;
float dist = camPos.y - h;
return Float.isFinite(dist) ? Math.max(1f, dist) : CAM_SPEED;
}
private void updateCamera(float tpf) {
@@ -1567,6 +1593,12 @@ public class TerrainEditorState extends BaseAppState {
if (scroll != 0)
camPos.addLocal(cam.getDirection().mult(scroll * FastMath.clamp(terrainDist, 5f, CAM_SPEED) * 0.02f));
// NaN-Sanitierung (z.B. durch terrain.getHeight()-Anomalie propagiert)
if (!Float.isFinite(camPos.x) || !Float.isFinite(camPos.y) || !Float.isFinite(camPos.z)) {
camPos.set(0f, DEFAULT_CAM_Y, 0f);
}
camPos.y = FastMath.clamp(camPos.y, -200f, MAX_CAM_Y);
cam.setLocation(camPos);
}

View File

@@ -10,6 +10,7 @@ import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
@@ -19,6 +20,7 @@ import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Texture;
@@ -40,6 +42,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.ConcurrentHashMap;
/**
@@ -117,6 +121,23 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator;
// ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */
private Node basePlaneNode;
private Geometry basePlane;
/** Chunk-Gitter (128 × 128 Einheiten) als Debugging-Hilfe. */
private Geometry chunkGrid;
// ── Wireframe-Modus ───────────────────────────────────────────────────────
/** true wenn im Moment Wireframe aktiv ist. */
private boolean wireframeActive = false;
/** Alle Materialien, die durch Wireframe verändert wurden (zum Zurücksetzen). */
private final Set<Material> wireframedMaterials = new HashSet<>();
/** Vorheriger Layer-Zustand für Einstieg/Ausstieg-Erkennung. */
private boolean prevLayerWasVoxel = false;
// ── LOD-Rebuild-Queue ─────────────────────────────────────────────────────
/** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */
@@ -124,6 +145,17 @@ public class VoxelEditorState extends BaseAppState {
/** Chunks mit fertigen LOD1/2-Meshes, die im JME-Thread übernommen werden. */
private final ConcurrentLinkedQueue<Runnable> lodResultQueue = new ConcurrentLinkedQueue<>();
// ── Undo / Redo ───────────────────────────────────────────────────────────
private static final int MAX_UNDO = 10;
private record UndoEntry(Map<Long, byte[]> before, Map<Long, byte[]> after) {}
private final Deque<UndoEntry> undoStack = new ArrayDeque<>();
private final Deque<UndoEntry> redoStack = new ArrayDeque<>();
private final Map<Long, byte[]> actionBefore = new HashMap<>();
private boolean actionInProgress = false;
// ── Konstruktor ───────────────────────────────────────────────────────────
public VoxelEditorState(SharedInput input) {
@@ -153,6 +185,14 @@ public class VoxelEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
// Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter
basePlaneNode = new Node("voxelBasePlaneNode");
basePlane = buildBasePlane();
chunkGrid = buildChunkGrid();
basePlaneNode.attachChild(basePlane);
basePlaneNode.attachChild(chunkGrid);
app.getRootNode().attachChild(basePlaneNode);
// Alle vorhandenen .blvc-Dateien laden
List<VoxelChunk> loaded = VoxelChunkIO.loadAll();
for (VoxelChunk chunk : loaded) {
@@ -167,7 +207,9 @@ public class VoxelEditorState extends BaseAppState {
protected void cleanup(Application app) {
executor.shutdownNow();
voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (wireframeActive) applyWireframe(false);
nodes.clear();
chunks.clear();
}
@@ -188,7 +230,14 @@ public class VoxelEditorState extends BaseAppState {
if (input.bakeVoxelsRequested) {
input.bakeVoxelsRequested = false;
List<VoxelChunk> snapshot = new ArrayList<>(chunks.values());
executor.submit(() -> bakeAll(snapshot));
executor.submit(() -> {
try {
bakeAll(snapshot);
} catch (Throwable e) {
log.error("Bake fehlgeschlagen: {}", e.getMessage(), e);
input.bakeStatusMsg = "FEHLER: " + e.getMessage();
}
});
}
// Voxel-Texturen aktualisiert?
@@ -197,6 +246,17 @@ public class VoxelEditorState extends BaseAppState {
applyTextures(voxelMaterial);
}
// Layer-Wechsel erkennen → Referenzebene und Wireframe steuern
boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL;
if (isVoxelLayer != prevLayerWasVoxel) {
prevLayerWasVoxel = isVoxelLayer;
onVoxelLayerChanged(isVoxelLayer);
}
// Wireframe-Toggle während LAYER_VOXEL (Button-Klick im Panel)
if (isVoxelLayer && wireframeActive != input.voxelWireframeEnabled) {
applyWireframe(input.voxelWireframeEnabled);
}
// Nur aktiv wenn LAYER_VOXEL gesetzt
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
idleSinceEdit = 0f;
@@ -205,6 +265,12 @@ public class VoxelEditorState extends BaseAppState {
return;
}
// Undo/Redo-Aktionsgrenzen und Anfragen verarbeiten
if (input.voxelActionStarted) { input.voxelActionStarted = false; beginVoxelAction(); }
if (input.voxelActionFinished) { input.voxelActionFinished = false; finishVoxelAction(); }
if (input.voxelUndoRequested) { input.voxelUndoRequested = false; applyUndo(); }
if (input.voxelRedoRequested) { input.voxelRedoRequested = false; applyRedo(); }
// Edit-Queue verarbeiten (max. MAX_EDITS_PER_FRAME)
// Edits nur akkumulieren; Mesh-Rebuild am Frameende einmal pro Chunk.
int processed = 0;
@@ -322,7 +388,9 @@ public class VoxelEditorState extends BaseAppState {
Vector3f bestPos = null;
Vector3f bestNorm = new Vector3f(0, 1, 0);
if (terrainNode != null) {
// Im Voxel-Layer nur Voxel-Geometrie und die Basis-Referenzebene treffen,
// nicht das Heightmap-Terrain (das würde Voxel auf der falschen Höhe erzeugen).
if (terrainNode != null && input.activeLayer != SharedInput.LAYER_VOXEL) {
terrainNode.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
@@ -341,11 +409,26 @@ public class VoxelEditorState extends BaseAppState {
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
bestDist = cr.getDistance();
bestPos = cr.getContactPoint();
bestNorm = cr.getContactNormal() != null
? cr.getContactNormal().normalize()
: new Vector3f(0, 1, 0);
}
results.clear();
}
// Basis-Terrain-Referenzebene (y = -10) als Raycast-Ziel
if (basePlaneNode != null && basePlane != null
&& basePlane.getCullHint() != Spatial.CullHint.Always) {
basePlaneNode.collideWith(ray, results);
if (results.size() > 0) {
CollisionResult cr = results.getClosestCollision();
if (cr.getDistance() < bestDist) {
bestPos = cr.getContactPoint();
bestNorm = new Vector3f(0, 1, 0);
}
}
}
return bestPos != null ? new Hit(bestPos, bestNorm) : null;
@@ -354,28 +437,36 @@ public class VoxelEditorState extends BaseAppState {
/**
* Wendet den gewählten Modus an.
*
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Spalten ab Terrain-Oberfläche nach oben (links)
* bzw. Abtragen nach unten (rechts). Verhalten analog zum Terrain-Tool.
* Vertikal (horizontal=false):
* Modi 0-3: Säulen ab Terrain-Oberfläche nach oben/unten. Analog zum Terrain-Tool.
* Modus 4 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum.
*
* Modus 4 (Klippe): Scheiben-Pinsel entlang der Oberflächennormale rechts = entfernen.
*
* Modus 5 (Aushöhlen): Kugel-Entfernen mit nach innen versetztem Zentrum.
* Horizontal (horizontal=true):
* Modi 0-3: Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
* Modus 4 (Aushöhlen): wie vertikal (Kugel-Entfernen).
*/
private void applyEdit(Hit hit, int action) {
float radius = (float) input.voxelTool.brushRadius.getValue();
float strength = (float) input.voxelTool.brushStrength.getValue();
int modeIdx = input.voxelTool.mode.getSelectedIndex();
boolean isHorizontal = input.voxelTool.horizontal;
boolean isSlab = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_ADD);
boolean isCave = (modeIdx == de.blight.editor.tool.VoxelTool.MODE_REMOVE);
boolean isColumn = !isSlab && !isCave;
boolean isColumn = !isCave;
boolean lower = action < 0;
Vector3f N = hit.normal;
Vector3f N = hit.normal();
float wx = hit.pos.x, wy = hit.pos.y, wz = hit.pos.z;
// Plateau-Rechtsklick: Voxel- und Terrain-Höhe sampeln, Maximum als Ziel speichern
if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
// Horizontaler Modus: bei flacher Fläche (Normal.y > 0.7) nichts tun
if (isHorizontal && isColumn) {
if (Math.abs(N.y) > 0.7f) return;
float nhLen = (float) Math.sqrt(N.x*N.x + N.z*N.z);
if (nhLen < 0.1f) return;
}
// Plateau-Rechtsklick (nur vertikal): Voxel- und Terrain-Höhe sampeln
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
float h = columnTopWorldY(wx, wz);
TerrainEditorState tes = getStateManager().getState(TerrainEditorState.class);
if (tes != null) {
@@ -396,8 +487,8 @@ public class VoxelEditorState extends BaseAppState {
wz -= N.z * radius * 0.6f;
}
// Spalten-Modi brauchen nur XZ-Radius; Slab/Cave brauchen auch Stärke in Y
float worldExtent = isColumn ? radius + 2f : radius + strength + 2f;
// Vertikale Spalten-Modi brauchen nur XZ-Radius; alle anderen auch Stärke
float worldExtent = (isColumn && !isHorizontal) ? radius + 2f : radius + strength + 2f;
int cxMin = VoxelChunk.worldXToCx(wx - worldExtent);
int cxMax = VoxelChunk.worldXToCx(wx + worldExtent);
@@ -406,29 +497,48 @@ public class VoxelEditorState extends BaseAppState {
int cyMin = VoxelChunk.worldYToCy(wy - worldExtent);
int cyMax = VoxelChunk.worldYToCy(wy + worldExtent);
// Smooth-Modus: Slope-Parameter vorab berechnen (für beide Klick-Varianten)
// Smooth-Modus: Slope-Parameter vorab berechnen (nur vertikal)
float[] slopeParams = null;
if (isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
slopeParams = computeSlopeParams(wx, wz, radius);
}
float plateauTargetH = (float) input.voxelTool.plateauTarget.getValue();
// Zurücksetzen-Modus: separate Behandlung ohne Chunk-Erzeugung
if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_RESET) {
applyReset(wx, wz, radius);
return;
}
for (int cz = czMin; cz <= czMax; cz++) {
for (int cx = cxMin; cx <= cxMax; cx++) {
for (int cy = cyMin; cy <= cyMax; cy++) {
VoxelChunk chunk = getOrCreateChunk(cx, cy, cz);
// Aushöhlen: nur existierende Chunks bearbeiten, keine neuen anlegen
VoxelChunk chunk = isCave
? chunks.get(chunkKey(cx, cy, cz))
: getOrCreateChunk(cx, cy, cz);
if (chunk == null || (isCave && chunk.isEmpty())) continue;
float lx = VoxelChunk.worldXToLocal(wx, cx);
float ly = VoxelChunk.worldYToLocal(wy, cy);
float lz = VoxelChunk.worldZToLocal(wz, cz);
long key = chunkKey(cx, cy, cz);
float lx = VoxelChunk.worldXToLocal(wx, cx);
float ly = VoxelChunk.worldYToLocal(wy, cy);
float lz = VoxelChunk.worldZToLocal(wz, cz);
snapshotChunkBefore(key, chunk);
if (isCave) {
chunk.applyBrush(lx, ly, lz, radius, Byte.MIN_VALUE, (byte)0);
} else if (isSlab) {
applySlabBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, lower);
// Graduelles Aushöhlen mit vollem Radius (passt zum Indikator).
// Stärke bestimmt Dichte-Abbau pro Tick (je höher, desto aggressiver).
int step = Math.max(8, (int)(strength * 2f));
chunk.reduceDensity(lx, ly, lz, radius, step);
chunk.pruneIsolated(lx, ly, lz, radius);
} else if (isHorizontal) {
applyHorizontalBrush(chunk, cx, cy, cz, wx, wy, wz, N, radius, strength, modeIdx, lower);
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU) {
applyPlateauColumn(chunk, cx, cy, cz, wx, wz, radius, plateauTargetH);
// Stärke steuert wie schnell sich die Spalten dem Ziel annähern
final float target = plateauTargetH;
applyColumnToTarget(chunk, cx, cy, cz, wx, wz, radius, strength, coord -> target);
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
if (lower) {
applyCliffColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
@@ -439,7 +549,6 @@ public class VoxelEditorState extends BaseAppState {
applyColumnBrush(chunk, cx, cy, cz, wx, wz, radius, strength, modeIdx, lower);
}
long key = chunkKey(cx, cy, cz);
// Node erst anlegen wenn tatsächlich Daten vorhanden
if (!chunk.isEmpty() && !nodes.containsKey(key)) {
addNodeForChunk(key, chunk);
@@ -451,15 +560,29 @@ public class VoxelEditorState extends BaseAppState {
}
/**
* Scheiben-Pinsel für den Klippe-Modus.
* remove=false: Voxel senkrecht zur Normalen aufbauen.
* remove=true: dieselbe Form entfernen (Rechtsklick).
* Horizontaler Brush: baut Voxel in Richtung der Flächennormale auf (oder ab).
* Das Profil (Sinus/Spike/Plateau/Smooth) bestimmt die Tiefe entlang der Normalen
* abhängig vom Abstand zur Brush-Mitte in der Flächen-Ebene (Y + tangential).
* remove=true: Rechtsklick Voxel entfernen.
*/
private void applySlabBrush(VoxelChunk chunk, int cx, int cy, int cz,
float hitWX, float hitWY, float hitWZ,
Vector3f N, float radius, float strength,
boolean remove) {
float extent = radius + strength + 1f;
private void applyHorizontalBrush(VoxelChunk chunk, int cx, int cy, int cz,
float hitWX, float hitWY, float hitWZ,
Vector3f N, float radius, float strength,
int mode, boolean remove) {
// Horizontale Normalenkomponente normalisieren
float nhx = N.x, nhz = N.z;
float nhLen = (float) Math.sqrt(nhx*nhx + nhz*nhz);
if (nhLen < 0.1f) return;
nhx /= nhLen; nhz /= nhLen;
// Tangente in XZ (senkrecht zur Normalen)
float tanx = -nhz, tanz = nhx;
float maxDepth = Math.max(1f, strength / 10f);
float overlap = 1.0f;
float r2 = radius * radius;
float extent = radius + maxDepth + 1f;
float lhX = VoxelChunk.worldXToLocal(hitWX, cx);
float lhY = VoxelChunk.worldYToLocal(hitWY, cy);
float lhZ = VoxelChunk.worldZToLocal(hitWZ, cz);
@@ -471,10 +594,6 @@ public class VoxelEditorState extends BaseAppState {
int z0 = Math.max(0, (int)(lhZ - extent));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lhZ + extent));
float nx = N.x, ny = N.y, nz = N.z;
float r2 = radius * radius;
float overlap = 1.0f;
for (int ly = y0; ly <= y1; ly++) {
float wy = VoxelChunk.toWorldY(cy, ly);
float dy = wy - hitWY;
@@ -485,12 +604,20 @@ public class VoxelEditorState extends BaseAppState {
float wx = VoxelChunk.toWorldX(cx, lx);
float dx = wx - hitWX;
float along = dx*nx + dy*ny + dz*nz;
float slabThick = Math.max(1f, strength / 10f);
if (along < -overlap || along > slabThick) continue;
float perpSq = dx*dx + dy*dy + dz*dz - along*along;
// Abstand in der Flächen-Ebene (Y + tangential in XZ)
float projTan = dx*tanx + dz*tanz;
float perpSq = projTan*projTan + dy*dy;
if (perpSq > r2) continue;
// Position entlang der Normalen
float projN = dx*nhx + dz*nhz;
float t = (float) Math.sqrt(perpSq) / radius;
float falloff = computeFalloff(mode, t);
float currDepth = maxDepth * falloff;
if (projN < -overlap || projN > currDepth) continue;
if (remove) {
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
} else {
@@ -542,26 +669,35 @@ public class VoxelEditorState extends BaseAppState {
int colStep = (int)(stepBase * falloff);
if (colStep < 1) continue;
// Terrain-Höhe: ceil stellt sicher, dass Voxel nie unterhalb Terrain beginnen
float terrainH = terrainH(wx, wz);
int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int) Math.ceil(VoxelChunk.worldYToLocal(terrainH, cy))));
// Unterirdische Voxel löschen (kein Überhang > 90°)
for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
if (!lower) {
// Aktuellen Säulen-Top finden (höchster Solid-Voxel ≥ terrainLY)
int currentTop = terrainLY; // Fallback: direkt auf Terrain starten
for (int ly = VoxelChunk.SIZE - 1; ly >= terrainLY; ly--) {
// Höchsten Solid-Voxel in dieser Spalte suchen
int currentTop = -1;
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
}
// Säule um colStep erhöhen, dabei Basis ab terrainLY immer füllen
if (currentTop < 0) {
if (cy == -1) {
// Basis-Ebene: Ankerpunkt bei ly=118 (Welt-Y = -10)
currentTop = 118;
} else {
// Nur weiterwachsen wenn der darunterliegende Chunk bis an die
// Grenze reicht (ly ≥ SIZE-3), sonst würde ein losgelöster Klumpen entstehen
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz));
if (below == null) continue;
boolean belowAtBoundary = false;
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) {
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; }
}
if (!belowAtBoundary) continue;
currentTop = 0;
}
}
int newTop = Math.min(VoxelChunk.SIZE - 1, currentTop + colStep);
for (int ly = terrainLY; ly <= newTop; ly++) {
for (int ly = currentTop; ly <= newTop; ly++) {
chunk.setDensity(lx, ly, lz, (byte) 127);
}
} else {
// Höchsten Solid-Voxel finden (egal ob über oder unter terrainLY)
// Höchsten Solid-Voxel finden
int currentTop = -1;
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
@@ -576,6 +712,48 @@ public class VoxelEditorState extends BaseAppState {
}
}
// ── Zurücksetzen-Pinsel ───────────────────────────────────────────────────
/** Setzt alle vorhandenen Chunks im Pinselbereich auf das Basis-Terrain zurück (y=-10). */
private void applyReset(float brushWX, float brushWZ, float radius) {
float halfChunk = VoxelChunk.CELLS / 2f;
for (VoxelChunk chunk : new ArrayList<>(chunks.values())) {
float ccx = chunk.cx * VoxelChunk.CELLS - 2048f + halfChunk;
float ccz = chunk.cz * VoxelChunk.CELLS - 2048f + halfChunk;
if (Math.abs(ccx - brushWX) > radius + halfChunk) continue;
if (Math.abs(ccz - brushWZ) > radius + halfChunk) continue;
applyResetBrush(chunk, chunk.cx, chunk.cy, chunk.cz, brushWX, brushWZ, radius);
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
if (!chunk.isEmpty() && !nodes.containsKey(key)) addNodeForChunk(key, chunk);
dirtyChunksThisFrame.add(key);
}
}
/** Löscht alle Voxel im Pinselbereich (setzt auf Luft). Die basePlane zeigt das Basis-Terrain. */
private void applyResetBrush(VoxelChunk chunk, int cx, int cy, int cz,
float brushWX, float brushWZ, float radius) {
float lxC = VoxelChunk.worldXToLocal(brushWX, cx);
float lzC = VoxelChunk.worldZToLocal(brushWZ, cz);
int x0 = Math.max(0, (int)(lxC - radius));
int x1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lxC + radius));
int z0 = Math.max(0, (int)(lzC - radius));
int z1 = Math.min(VoxelChunk.SIZE - 1, (int) Math.ceil(lzC + radius));
float r2 = radius * radius;
for (int lz = z0; lz <= z1; lz++) {
float wz = VoxelChunk.toWorldZ(cz, lz);
float dz = wz - brushWZ;
for (int lx = x0; lx <= x1; lx++) {
float wx = VoxelChunk.toWorldX(cx, lx);
float dx = wx - brushWX;
if (dx*dx + dz*dz > r2) continue;
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
if (chunk.getDensity(lx, ly, lz) != Byte.MIN_VALUE)
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
}
}
}
}
// ── Terrain-Höhe (schneller O(1)-Zugriff) ─────────────────────────────────
private float terrainH(float worldX, float worldZ) {
@@ -643,7 +821,7 @@ public class VoxelEditorState extends BaseAppState {
}
}
}
return terrainH(worldX, worldZ);
return -10f; // Kein Voxel vorhanden → Basis-Niveau
}
/**
@@ -687,19 +865,30 @@ public class VoxelEditorState extends BaseAppState {
}
float currentTopWY = currentTopLY >= 0
? VoxelChunk.toWorldY(cy, currentTopLY)
: terrainH(wx, wz);
: -10f; // Keine Voxel → Basis-Niveau als Referenz
float diff = targetH - currentTopWY;
if (Math.abs(diff) < 0.5f) continue;
int terrainLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int) Math.ceil(VoxelChunk.worldYToLocal(terrainH(wx, wz), cy))));
// Unterirdische Voxel immer leeren (kein Überhang > 90°)
for (int ly = 0; ly < terrainLY; ly++) chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
if (diff > 0) {
int startLY = currentTopLY >= 0 ? Math.max(currentTopLY, terrainLY) : terrainLY;
// Erhöhen
int startLY;
if (currentTopLY >= 0) {
startLY = currentTopLY;
} else if (cy == -1) {
startLY = 118;
} else {
VoxelChunk below = chunks.get(chunkKey(cx, cy - 1, cz));
if (below == null) continue;
boolean belowAtBoundary = false;
for (int bly = VoxelChunk.SIZE - 1; bly >= VoxelChunk.SIZE - 3; bly--) {
if (below.getDensity(lx, bly, lz) > 0) { belowAtBoundary = true; break; }
}
if (!belowAtBoundary) continue;
startLY = 0;
}
int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step);
for (int ly = terrainLY; ly <= newTop; ly++) {
for (int ly = startLY; ly <= newTop; ly++) {
chunk.setDensity(lx, ly, lz, (byte) 127);
}
} else {
@@ -758,14 +947,9 @@ public class VoxelEditorState extends BaseAppState {
float dx = wx - brushWX;
if (dx*dx + dz*dz > r2) continue;
float th = terrainH(wx, wz);
for (int ly = 0; ly < VoxelChunk.SIZE; ly++) {
float wy = VoxelChunk.toWorldY(cy, ly);
if (wy < th) {
// Unterhalb Terrain: immer leeren, kein Überhang möglich
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
continue;
}
if (wy < -10f) continue; // Fundament unterhalb y=-10 nicht antasten
if (wy <= targetH) {
chunk.setDensity(lx, ly, lz, (byte) 127);
} else {
@@ -812,9 +996,21 @@ public class VoxelEditorState extends BaseAppState {
List<VoxelChunk> nonEmpty = new java.util.ArrayList<>();
for (VoxelChunk c : toProcess) { if (!c.isEmpty()) nonEmpty.add(c); }
// Chunks ohne .blvc-Datei überspringen (noch nie gespeichert).
nonEmpty.removeIf(c -> !VoxelChunkIO.exists(c.cx, c.cy, c.cz));
// Chunks ohne nennenswerte Geometrie überspringen (nur Brush-Randberührungen,
// solidYSpan < 2 → gleiche Schwelle wie der Game-Loader).
nonEmpty.removeIf(c -> c.solidYSpan() < 2);
if (nonEmpty.isEmpty()) {
input.bakeStatusMsg = "Nichts zu backen (keine nicht-leeren Chunks).";
return;
}
// bakeTotal sofort setzen, damit die Fortschrittsanzeige schon während des Blurs läuft
input.bakeDone = 0;
input.bakeTotal = nonEmpty.size();
log.info("Bake gestartet: {} Chunks", nonEmpty.size());
Map<Long, VoxelChunk> allOriginal = new HashMap<>();
for (VoxelChunk c : nonEmpty) allOriginal.put(chunkKey(c.cx, c.cy, c.cz), c);
@@ -863,6 +1059,7 @@ public class VoxelEditorState extends BaseAppState {
cBuf[c.idx(bx, VoxelChunk.CELLS, bz)] = tBuf[c.idx(bx, 0, bz)];
}
input.blurIterDone = 0;
for (int iter = 0; iter < 7; iter++) {
Map<Long, float[]> nextBufs = new HashMap<>();
for (VoxelChunk c : nonEmpty) {
@@ -889,6 +1086,7 @@ public class VoxelEditorState extends BaseAppState {
nextBufs.put(k, next);
}
curBufs = nextBufs;
input.blurIterDone = iter + 1;
}
// Blur-Ergebnisse in VoxelChunks umwandeln
@@ -934,9 +1132,26 @@ public class VoxelEditorState extends BaseAppState {
// NICHT ENTFERNEN! Wurde schon einmal versehentlich rückgängig gemacht.
// Ohne diesen Block entsteht am Terrain-Übergang ein hässlicher Überhang.
for (VoxelChunk blurred : blurredMap.values()) {
// Extrapolation nur sinnvoll, wenn der Chunk darunter (cy-1) ebenfalls
// solide Voxel an seiner Oberkante hat. Andernfalls handelt es sich um
// einen freistehenden Überhang, einen Höhleneingang oder eine Bergkante
// über Luft dort würde die Extrapolation fälschlicherweise die Luft
// unter der Geometrie mit Solid-Dichte füllen und die Unterseite zerstören.
long belowKey = chunkKey(blurred.cx, blurred.cy - 1, blurred.cz);
VoxelChunk blurredBelow = blurredMap.get(belowKey);
for (int lz = 0; lz < blurN; lz++) {
for (int lx = 0; lx < blurN; lx++) {
// Spalte überspringen, wenn der Chunk darunter hier oben keine
// soliden Voxel hat (kein durchgehender Terrain-Block nach unten).
if (blurredBelow == null) continue;
boolean colBelowHasSolid = false;
for (int ly2 = VoxelChunk.CELLS - 3; ly2 <= VoxelChunk.CELLS; ly2++) {
if (blurredBelow.getDensity(lx, ly2, lz) > 0) { colBelowHasSolid = true; break; }
}
if (!colBelowHasSolid) continue;
// Untersten festen Voxel in dieser Spalte finden
int yBot = -1;
for (int ly = 0; ly < blurN; ly++) {
@@ -944,6 +1159,17 @@ public class VoxelEditorState extends BaseAppState {
}
if (yBot < 0 || yBot + 2 >= blurN) continue;
// Interne Lücke prüfen: Solid → Luft → Solid von yBot aufwärts
// bedeutet Höhle oder Decken-Überhang → nicht extrapolieren.
boolean hasVoid = false;
boolean inAirAbove = false;
for (int ly = yBot + 1; ly < blurN; ly++) {
boolean s = blurred.getDensity(lx, ly, lz) > 0;
if (!s) { inAirAbove = true; }
else if (inAirAbove) { hasVoid = true; break; }
}
if (hasVoid) continue;
// Steigung aus den zwei Voxeln darüber bestimmen.
// slope < 0: Dichte nimmt nach unten ab (typisch für eine Oberfläche).
float d1 = blurred.getDensity(lx, yBot + 1, lz);
@@ -979,9 +1205,26 @@ public class VoxelEditorState extends BaseAppState {
baked++;
input.bakeDone = baked;
}
// Voxel-Daten löschen: Disk-Dateien entfernen, In-Memory leeren, Szene-Nodes entfernen
for (VoxelChunk chunk : nonEmpty) {
chunk.clear();
try { VoxelChunkIO.delete(chunk.cx, chunk.cy, chunk.cz); }
catch (Exception e) { log.warn("Voxel-Datei löschen fehlgeschlagen ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage()); }
}
app.enqueue(() -> {
for (VoxelChunk chunk : nonEmpty) {
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
VoxelChunkNode node = nodes.remove(key);
if (node != null) node.removeFromParent();
chunks.remove(key);
}
});
String msg = "Fertig: " + baked + " Chunk" + (baked != 1 ? "s" : "") + " gebacken.";
// bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt
input.bakeStatusMsg = msg;
input.bakeStatusMsg = msg;
input.sculptRescanNeeded = true;
log.info("Voxel-Bake abgeschlossen {}.", msg);
}
@@ -1063,9 +1306,11 @@ public class VoxelEditorState extends BaseAppState {
log.warn("Chunk ({},{},{}) laden fehlgeschlagen, neu erstellt: {}",
cx, cy, cz, e.getMessage());
chunk = new VoxelChunk(cx, cy, cz);
fillBaseTerrainChunk(chunk);
}
} else {
chunk = new VoxelChunk(cx, cy, cz);
fillBaseTerrainChunk(chunk);
}
chunks.put(key, chunk);
return chunk;
@@ -1084,6 +1329,93 @@ public class VoxelEditorState extends BaseAppState {
return node;
}
// ── Intern: Undo / Redo ───────────────────────────────────────────────────
private void beginVoxelAction() {
if (actionInProgress) return;
actionInProgress = true;
actionBefore.clear();
redoStack.clear();
}
private void snapshotChunkBefore(long key, VoxelChunk chunk) {
if (!actionInProgress || actionBefore.containsKey(key)) return;
actionBefore.put(key, chunk.getDensityCopy());
}
private void finishVoxelAction() {
if (!actionInProgress) return;
actionInProgress = false;
if (actionBefore.isEmpty()) return;
Map<Long, byte[]> changedBefore = new HashMap<>();
Map<Long, byte[]> changedAfter = new HashMap<>();
for (Map.Entry<Long, byte[]> e : actionBefore.entrySet()) {
long key = e.getKey();
VoxelChunk c = chunks.get(key);
byte[] after = (c == null || c.isEmpty()) ? null : c.getDensityCopy();
if (!Arrays.equals(e.getValue(), after)) {
changedBefore.put(key, e.getValue());
changedAfter.put(key, after);
}
}
// Neu angelegte Chunks (gab es vor der Aktion nicht)
for (Map.Entry<Long, VoxelChunk> e : chunks.entrySet()) {
long key = e.getKey();
if (!actionBefore.containsKey(key) && !e.getValue().isEmpty()) {
changedBefore.put(key, null);
changedAfter.put(key, e.getValue().getDensityCopy());
}
}
if (!changedBefore.isEmpty()) {
undoStack.addFirst(new UndoEntry(changedBefore, changedAfter));
while (undoStack.size() > MAX_UNDO) undoStack.removeLast();
}
actionBefore.clear();
}
private void applyUndo() {
if (undoStack.isEmpty()) return;
UndoEntry entry = undoStack.removeFirst();
redoStack.addFirst(entry);
while (redoStack.size() > MAX_UNDO) redoStack.removeLast();
restoreChunkState(entry.before());
}
private void applyRedo() {
if (redoStack.isEmpty()) return;
UndoEntry entry = redoStack.removeFirst();
undoStack.addFirst(entry);
while (undoStack.size() > MAX_UNDO) undoStack.removeLast();
restoreChunkState(entry.after());
}
private void restoreChunkState(Map<Long, byte[]> snapshot) {
for (Map.Entry<Long, byte[]> e : snapshot.entrySet()) {
long key = e.getKey();
byte[] d = e.getValue();
if (d == null) {
chunks.remove(key);
VoxelChunkNode n = nodes.remove(key);
if (n != null) n.removeFromParent();
} else {
VoxelChunk c = chunks.get(key);
if (c == null) {
int cx = (int)(key & 0xFFFF); if (cx >= 0x8000) cx -= 0x10000;
int cy = (int)((key >> 16) & 0xFFFF); if (cy >= 0x8000) cy -= 0x10000;
int cz = (int)((key >> 32) & 0xFFFF); if (cz >= 0x8000) cz -= 0x10000;
c = new VoxelChunk(cx, cy, cz);
chunks.put(key, c);
}
c.setDensityArray(d.clone());
c.dirty = true;
dirtyChunksThisFrame.add(key);
if (!nodes.containsKey(key)) addNodeForChunk(key, c);
}
}
}
// ── Intern: Hintergrund-LOD ───────────────────────────────────────────────
private void scheduleLodRebuild() {
@@ -1138,17 +1470,16 @@ public class VoxelEditorState extends BaseAppState {
private void applyTextures(Material mat) {
mat.setFloat("TexScale", 8f);
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot, input.voxelCeilSlot };
String[] colSlots = { "TexFlat", "TexSteep", "TexCeil" };
String[] normSlots = { "NormalMapFlat", "NormalMapSteep", "NormalMapCeil" };
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep","DisplacementMapCeil" };
int[] slotIdxs = { input.voxelFlatSlot, input.voxelSteepSlot };
String[] colSlots = { "TexFlat", "TexSteep" };
String[] normSlots = { "NormalMapFlat", "NormalMapSteep" };
String[] dispSlots = { "DisplacementMapFlat","DisplacementMapSteep" };
int[][] fallbackRgb = {
{100, 130, 60},
{110, 100, 90},
{ 70, 55, 45},
};
boolean anyDisp = false;
for (int i = 0; i < 3; i++) {
for (int i = 0; i < 2; i++) {
String texPath = slotTexPath(slotIdxs[i]);
String normPath = slotNormPath(slotIdxs[i]);
String dispPath = slotDispPath(slotIdxs[i]);
@@ -1256,11 +1587,27 @@ public class VoxelEditorState extends BaseAppState {
float jmeX = mx * (float) input.viewportScaleX;
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Hit hit = raycastHit(jmeX, jmeY);
Vector3f pos = hit != null ? hit.pos : null;
if (pos != null) {
if (hit != null) {
float r = (float) input.voxelTool.brushRadius.getValue();
brushIndicator.setLocalTranslation(pos.x, pos.y + 0.3f, pos.z);
// Leicht entlang der Flächen-Normalen versetzt, um Z-Fighting zu vermeiden
brushIndicator.setLocalTranslation(hit.pos.add(hit.normal().mult(0.05f)));
brushIndicator.setLocalScale(r, 1f, r);
// Disc-Normale (lokales Y) auf hit.normal() ausrichten
Vector3f axis = Vector3f.UNIT_Y.cross(hit.normal());
Quaternion rot = new Quaternion();
if (axis.lengthSquared() < 1e-6f) {
// Parallel oder antiparallel → Identität oder 180°-Kipp um X
rot.fromAngleNormalAxis(
Vector3f.UNIT_Y.dot(hit.normal()) > 0 ? 0f : FastMath.PI,
Vector3f.UNIT_X);
} else {
float angle = FastMath.acos(FastMath.clamp(Vector3f.UNIT_Y.dot(hit.normal()), -1f, 1f));
rot.fromAngleNormalAxis(angle, axis.normalizeLocal());
}
brushIndicator.setLocalRotation(rot);
brushIndicator.setCullHint(Spatial.CullHint.Inherit);
} else {
brushIndicator.setCullHint(Spatial.CullHint.Always);
@@ -1320,4 +1667,124 @@ public class VoxelEditorState extends BaseAppState {
public static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
}
// ── Basis-Terrain-Füllung ─────────────────────────────────────────────────
/**
* Neu erstellte Chunks starten leer (Luft).
* Das Basis-Terrain bei y=-10 wird durch die basePlane visualisiert;
* Voxel entstehen nur dort, wo der Nutzer sculpted.
*/
private static void fillBaseTerrainChunk(VoxelChunk chunk) {
// absichtlich leer kein Prefill
}
// ── Referenzebene bei y = -10 ─────────────────────────────────────────────
private Geometry buildBasePlane() {
// Welt-Ausdehnung: X und Z von -2048 bis +2048 → Größe 4096×4096
Quad quad = new Quad(4096f, 4096f);
Geometry geo = new Geometry("voxelBasePlane", quad);
// Quad liegt im XY-Raum; nach -90° um X rotieren → horizontale XZ-Ebene
Quaternion rot = new Quaternion();
rot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
geo.setLocalRotation(rot);
// Nach Rotation: x 0..4096 → x -2048..2048, z geht von +2048 nach -2048
geo.setLocalTranslation(-2048f, -10f, 2048f);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.15f, 0.55f, 0.15f, 0.22f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always); // standardmäßig versteckt
return geo;
}
private Geometry buildChunkGrid() {
float half = 2048f;
float step = VoxelChunk.CELLS; // 128 Einheiten pro Chunk
int divs = (int)(half * 2 / step); // 32 Chunks pro Achse
float y = -9.5f; // knapp über der basePlane bei -10
// Linien entlang Z (für jeden X-Abschnitt) + Linien entlang X (für jeden Z-Abschnitt)
int totalLines = (divs + 1) * 2;
FloatBuffer pos = BufferUtils.createFloatBuffer(totalLines * 2 * 3);
IntBuffer idx = BufferUtils.createIntBuffer(totalLines * 2);
int v = 0;
for (int i = 0; i <= divs; i++) {
float x = -half + i * step;
pos.put(x).put(y).put(-half);
pos.put(x).put(y).put( half);
idx.put(v++).put(v++);
}
for (int i = 0; i <= divs; i++) {
float z = -half + i * step;
pos.put(-half).put(y).put(z);
pos.put( half).put(y).put(z);
idx.put(v++).put(v++);
}
pos.rewind(); idx.rewind();
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();
Geometry geo = new Geometry("voxelChunkGrid", mesh);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.35f, 0f, 1f));
geo.setMaterial(mat);
geo.setCullHint(Spatial.CullHint.Always);
return geo;
}
// ── Layer-Wechsel-Reaktion ─────────────────────────────────────────────────
private void onVoxelLayerChanged(boolean entered) {
if (basePlane != null)
basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
if (chunkGrid != null)
chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
applyWireframe(entered && input.voxelWireframeEnabled);
}
/** Zeigt/verbirgt die Voxel-Chunk-Nodes (wird vom SculptedMeshEditorState gesteuert). */
public void setChunksVisible(boolean visible) {
if (voxelRoot != null)
voxelRoot.setCullHint(visible ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
}
/** Gibt das Voxel-Material zurück, das SculptedMeshEditorState wiederverwenden kann. */
public Material getVoxelMaterial() { return voxelMaterial; }
// ── Wireframe-Hilfsmethoden ───────────────────────────────────────────────
/** Schaltet Wireframe für die gesamte Szene (außer Voxel-Root und Hilfsgeos) ein oder aus. */
private void applyWireframe(boolean enable) {
if (enable == wireframeActive) return;
wireframeActive = enable;
if (enable) {
wireframedMaterials.clear();
applyWireframeRecursive(app.getRootNode());
} else {
for (Material m : wireframedMaterials)
m.getAdditionalRenderState().setWireframe(false);
wireframedMaterials.clear();
}
}
private void applyWireframeRecursive(Spatial s) {
// Voxel-eigene Objekte nicht wireframen
if (s == voxelRoot || s == brushIndicator || s == basePlaneNode) return;
if (s instanceof Geometry geo && geo.getMaterial() != null) {
geo.getMaterial().getAdditionalRenderState().setWireframe(true);
wireframedMaterials.add(geo.getMaterial());
} else if (s instanceof Node node) {
for (Spatial child : new java.util.ArrayList<>(node.getChildren()))
applyWireframeRecursive(child);
}
}
}

View File

@@ -0,0 +1,37 @@
package de.blight.editor.tool;
import java.util.List;
/** Werkzeug zum direkten Sculpten gebackener Voxel-Meshes. */
public class SculptMeshTool extends EditorTool {
public static final int MODE_RAISE = 0;
public static final int MODE_LOWER = 1;
public static final int MODE_SMOOTH = 2;
public static final int MODE_FLATTEN = 3;
public static final int MODE_SELECT = 4;
public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus",
new String[]{"Anheben", "Absenken", "Glätten", "Abflachen", "Auswählen"},
MODE_RAISE,
new String[]{
"img/editor/terraintool_sinus.png",
"img/editor/terraintool_spike.png",
"img/editor/terraintool_smooth.png",
"img/editor/terraintool_plateau.png",
"img/editor/terraintool_select.png", // nicht vorhanden → zeigt "Au"
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 3.0, 0.5, 20.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 1.0, 0.1, 10.0);
@Override public String getName() { return "Sculpt"; }
@Override
public List<ChoiceToolParameter> getChoiceParameters() { return List.of(mode); }
@Override
public List<ToolParameter> getParameters() { return List.of(brushRadius, brushStrength); }
}

View File

@@ -0,0 +1,23 @@
package de.blight.editor.tool;
import java.util.List;
public class StoneTool extends EditorTool {
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 10.0, 1.0, 100.0);
public final ToolParameter minSize = new ToolParameter("Min-Größe (m)", 0.3, 0.1, 5.0);
public final ToolParameter maxSize = new ToolParameter("Max-Größe (m)", 1.5, 0.2, 10.0);
public final ToolParameter density = new ToolParameter("Dichte (Klick)", 4.0, 1.0, 30.0);
/** Texturpfade für bis zu 3 Slots; "" = kein Texture (Fallback: Grau). */
public volatile String[] texturePaths = new String[]{"", "", ""};
public volatile boolean texturesChanged = false;
@Override public String getName() { return "Steine"; }
@Override public List<ChoiceToolParameter> getChoiceParameters() { return List.of(); }
@Override public List<ToolParameter> getParameters() {
return List.of(brushRadius, minSize, maxSize, density);
}
}

View File

@@ -3,11 +3,14 @@ package de.blight.editor.tool;
import java.util.List;
/**
* Voxel-Werkzeug für Klippen und Höhlen.
* Voxel-Werkzeug.
*
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben für Felstürme, Plateaus.
* Modus 4 (Klippe): Kugel-Pinsel ohne Terrain-Cleanup.
* Modus 5 (Aushöhlen): Entfernt Voxel.
* Modi 0-3 (Sinus/Spike/Plateau/Smooth): Säulen nach oben/unten für Felstürme, Plateaus.
* Modus 4 (Aushöhlen): Kugel-Entfernen.
* Modus 5 (Zurücksetzen): Setzt Voxel im Bereich auf das Basis-Niveau y=-10 zurück.
*
* horizontal=false (Vertikal): Säulen entlang Y-Achse.
* horizontal=true (Horizontal): Brush entlang der Flächennormale; bei flacher Fläche kein Effekt.
*
* Texturierung erfolgt automatisch anhand der Flächennormale:
* Normal.y > 0.5 → TexFlat (flache Flächen)
@@ -20,13 +23,13 @@ public class VoxelTool extends EditorTool {
public static final int MODE_SPIKE = 1;
public static final int MODE_PLATEAU = 2;
public static final int MODE_SMOOTH = 3;
public static final int MODE_ADD = 4;
public static final int MODE_REMOVE = 5;
public static final int MODE_REMOVE = 4;
public static final int MODE_RESET = 5;
public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus",
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Klippe", "Aushöhlen"},
MODE_ADD,
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen"},
MODE_SINUS,
new String[]{
"img/editor/terraintool_sinus.png",
"img/editor/terraintool_spike.png",
@@ -37,9 +40,12 @@ public class VoxelTool extends EditorTool {
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
public volatile boolean modeChanged = false;
public volatile boolean horizontal = false;
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
public volatile boolean plateauTargetChanged = false;
@Override public String getName() { return "Voxel"; }

View File

@@ -268,6 +268,8 @@ public class CraftingTableEditorView extends BorderPane {
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
case Fireplace -> "#ee6633";
case Kitchen -> "#88aa44";
};
}

View File

@@ -94,7 +94,7 @@ public class LocalizationEditorView extends BorderPane {
table.refresh();
});
table.getColumns().addAll(keyCol, valCol);
table.getColumns().addAll(List.of(keyCol, valCol));
VBox.setVgrow(table, Priority.ALWAYS);
Label hint = new Label("Doppelklick zum Bearbeiten eines Eintrags.");

View File

@@ -458,6 +458,8 @@ public class RecipeEditorView extends BorderPane {
case Smithy -> "#cc8833";
case Goldsmiths -> "#ddbb22";
case Workshop -> "#4488cc";
case Fireplace -> "#ee6633";
case Kitchen -> "#88aa44";
};
}

View File

@@ -0,0 +1,659 @@
package de.blight.editor.ui;
import de.blight.common.PlacedItem;
import de.blight.common.PlacedItemIO;
import de.blight.common.model.*;
import de.blight.editor.SharedInput;
import javafx.animation.AnimationTimer;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.function.Consumer;
/**
* Linke Hälfte des Tagesablauf-Editors.
*
* Zeigt eine Routine-Liste und einen Zeitblock-Editor.
* Raycasting-Ergebnisse werden via {@link SharedInput#routinePickedChanged}
* abgeholt und in die jeweiligen Felder geschrieben.
*
* Einbinden: Wird von EditorApp in einen SplitPane (links) neben
* dem worldViewport (rechts) gelegt, wenn der Nutzer „Tagesabläufe" öffnet.
*/
public class RoutineEditorView extends VBox {
// ── State ──────────────────────────────────────────────────────────────────
private final SharedInput input;
private final Path charDir;
private final List<NpcRoutine> routines = new ArrayList<>();
private NpcRoutine activeRoutine;
private RoutineBlock activeBlock;
/** Wird aufgerufen, sobald ein Punkt auf der Karte gepickt wurde. */
private Consumer<float[]> pendingPick;
private int savedLayer;
// ── Controls ───────────────────────────────────────────────────────────────
private ListView<String> routineList;
private Label coverageLabel;
private ListView<String> blockList;
private Label blockStatusLabel;
private VBox blockFormBox;
// Block-Formular
private Spinner<Integer> startSpin, endSpin;
private ComboBox<String> typeCombo;
private VBox dynamicBox;
// Dynamische Felder je Aktivitätstyp
private Label pointLabel; // zeigt gepickten Punkt
private ComboBox<String> interactableCombo; // UUID-basiert (PlacedItems)
private ComboBox<String> npcCombo; // für TALK
private ListView<String> waypointList; // für PATROL
private RadioButton rbPoint, rbInteractable; // für SIT
// Geladene Hilfsdaten
private final List<PlacedItem> placedItems = new ArrayList<>();
private final List<String> npcIds = new ArrayList<>();
// ── Polling-Timer ──────────────────────────────────────────────────────────
private final AnimationTimer pollTimer = new AnimationTimer() {
@Override public void handle(long now) {
if (pendingPick != null && input.routinePickedChanged) {
input.routinePickedChanged = false;
String raw = input.routinePickedPoint;
if (raw != null) {
String[] p = raw.split("\\|");
if (p.length == 3) {
try {
float x = Float.parseFloat(p[0]);
float y = Float.parseFloat(p[1]);
float z = Float.parseFloat(p[2]);
Consumer<float[]> cb = pendingPick;
pendingPick = null;
input.activeLayer = savedLayer;
cb.accept(new float[]{x, y, z});
} catch (NumberFormatException ignored) {}
}
}
}
}
};
// ══════════════════════════════════════════════════════════════════════════
public RoutineEditorView(SharedInput input, Path charDir) {
this.input = input;
this.charDir = charDir;
setSpacing(0);
setPadding(new Insets(6));
setStyle("-fx-background-color: #2b2b2b;");
loadPlacedItems();
loadNpcIds();
buildUi();
pollTimer.start();
}
// ── Datei-Hilfsmethoden ────────────────────────────────────────────────────
private void loadPlacedItems() {
try {
placedItems.addAll(PlacedItemIO.load());
} catch (IOException ignored) {}
}
private void loadNpcIds() {
if (charDir == null) return;
try (var s = java.nio.file.Files.list(charDir)) {
s.filter(p -> p.toString().endsWith(".character"))
.map(p -> p.getFileName().toString().replace(".character", ""))
.sorted()
.forEach(npcIds::add);
} catch (IOException ignored) {}
}
// ── UI-Aufbau ──────────────────────────────────────────────────────────────
private void buildUi() {
getChildren().addAll(buildRoutineSection(), new Separator(Orientation.HORIZONTAL), buildBlockSection());
}
private VBox buildRoutineSection() {
VBox box = new VBox(4);
box.setPadding(new Insets(0, 0, 6, 0));
Label title = styledLabel("Abläufe", true);
coverageLabel = styledLabel("", false);
coverageLabel.setTextFill(Color.GRAY);
routineList = new ListView<>();
routineList.setPrefHeight(120);
routineList.getSelectionModel().selectedIndexProperty().addListener((obs, ov, nv) -> {
int idx = nv.intValue();
activeRoutine = (idx >= 0 && idx < routines.size()) ? routines.get(idx) : null;
refreshBlockList();
});
Button addR = new Button("+");
Button renR = new Button("Umbenennen");
Button delR = new Button("");
addR.setOnAction(e -> addRoutine());
renR.setOnAction(e -> renameRoutine());
delR.setOnAction(e -> deleteRoutine());
HBox bar = new HBox(4, addR, renR, new Spacer(), delR);
bar.setAlignment(Pos.CENTER_LEFT);
box.getChildren().addAll(title, bar, routineList, coverageLabel);
return box;
}
private VBox buildBlockSection() {
VBox box = new VBox(4);
box.setPadding(new Insets(6, 0, 0, 0));
Label title = styledLabel("Zeitblöcke", true);
blockStatusLabel = styledLabel("— kein Ablauf gewählt —", false);
blockStatusLabel.setTextFill(Color.GRAY);
blockList = new ListView<>();
blockList.setPrefHeight(110);
blockList.getSelectionModel().selectedIndexProperty().addListener((obs, ov, nv) -> {
if (activeRoutine == null) return;
int idx = nv.intValue();
activeBlock = (idx >= 0 && idx < activeRoutine.getBlocks().size())
? activeRoutine.getBlocks().get(idx) : null;
refreshBlockForm();
});
Button addB = new Button("+");
Button delB = new Button("");
Button upB = new Button("");
Button dnB = new Button("");
addB.setOnAction(e -> addBlock());
delB.setOnAction(e -> deleteBlock());
upB .setOnAction(e -> moveBlock(-1));
dnB .setOnAction(e -> moveBlock(+1));
HBox bar = new HBox(4, addB, upB, dnB, new Spacer(), delB);
bar.setAlignment(Pos.CENTER_LEFT);
dynamicBox = new VBox(4);
blockFormBox = buildBlockForm();
box.getChildren().addAll(title, bar, blockList, blockStatusLabel, new Separator(Orientation.HORIZONTAL), blockFormBox, dynamicBox);
return box;
}
private VBox buildBlockForm() {
VBox form = new VBox(4);
form.setPadding(new Insets(4, 0, 4, 0));
form.setDisable(true);
startSpin = hourSpinner();
endSpin = hourSpinner();
startSpin.valueProperty().addListener((o, ov, nv) -> applyBlockTimes());
endSpin .valueProperty().addListener((o, ov, nv) -> applyBlockTimes());
HBox timeRow = new HBox(6,
styledLabel("Von", false), startSpin,
styledLabel("bis", false), endSpin,
styledLabel("Uhr", false));
timeRow.setAlignment(Pos.CENTER_LEFT);
typeCombo = new ComboBox<>();
typeCombo.getItems().addAll(
"Sitzen", "Stehen", "Reden", "Patrullieren", "Arbeiten", "Schlafen");
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> refreshDynamicFields());
form.getChildren().addAll(timeRow, typeCombo);
return form;
}
// ── Dynamische Felder ──────────────────────────────────────────────────────
private void refreshDynamicFields() {
dynamicBox.getChildren().clear();
if (activeBlock == null || typeCombo.getValue() == null) return;
switch (typeCombo.getValue()) {
case "Sitzen" -> buildSitFields();
case "Stehen" -> buildStandFields();
case "Reden" -> buildTalkFields();
case "Patrullieren"-> buildPatrolFields();
case "Arbeiten" -> buildInteractableFields("Arbeitsplatz");
case "Schlafen" -> buildInteractableFields("Schlafplatz");
}
applyActivityToBlock();
}
private void buildSitFields() {
rbPoint = new RadioButton("Punkt auf Karte");
rbInteractable = new RadioButton("Interactable");
ToggleGroup tg = new ToggleGroup();
rbPoint.setToggleGroup(tg);
rbInteractable.setToggleGroup(tg);
rbPoint.setSelected(true);
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
interactableCombo = itemCombo();
VBox pointRow = new VBox(2, pointLabel, pickBtn);
VBox itemRow = new VBox(2, interactableCombo);
itemRow.setManaged(false); itemRow.setVisible(false);
tg.selectedToggleProperty().addListener((o, ov, nv) -> {
boolean useItem = rbInteractable.isSelected();
pointRow.setManaged(!useItem); pointRow.setVisible(!useItem);
itemRow.setManaged(useItem); itemRow.setVisible(useItem);
applyActivityToBlock();
});
dynamicBox.getChildren().addAll(rbPoint, rbInteractable, pointRow, itemRow);
// Pre-fill from existing activity
if (activeBlock.getActivity() instanceof RoutineActivity act) {
if (act.getObjectUuid() != null) {
rbInteractable.setSelected(true);
selectItemCombo(interactableCombo, act.getObjectUuid());
} else if (act.getPosition() != null) {
setPointLabel(pointLabel, act.getPosition());
}
}
}
private void buildStandFields() {
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
dynamicBox.getChildren().addAll(styledLabel("Standpunkt:", false), pointLabel, pickBtn);
if (activeBlock.getActivity() != null && activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
}
private void buildTalkFields() {
pointLabel = pointLabel();
Button pickBtn = pickButton(xyz -> setPointLabel(pointLabel, xyz));
npcCombo = new ComboBox<>();
npcCombo.getItems().addAll(npcIds);
npcCombo.setMaxWidth(Double.MAX_VALUE);
npcCombo.setPromptText("NPC wählen…");
npcCombo.setOnAction(e -> applyActivityToBlock());
dynamicBox.getChildren().addAll(styledLabel("Gesprächspunkt:", false), pointLabel, pickBtn,
styledLabel("Gesprächspartner:", false), npcCombo);
if (activeBlock.getActivity() != null) {
if (activeBlock.getActivity().getPosition() != null)
setPointLabel(pointLabel, activeBlock.getActivity().getPosition());
if (activeBlock.getActivity().getTalkNpcId() != null)
npcCombo.setValue(activeBlock.getActivity().getTalkNpcId());
}
}
private void buildPatrolFields() {
waypointList = new ListView<>();
waypointList.setPrefHeight(80);
Button addWp = new Button("Punkt hinzufügen");
Button delWp = new Button("Letzten entfernen");
addWp.setOnAction(e -> startPick(xyz -> {
WorldPoint wp = new WorldPoint(xyz[0], xyz[1], xyz[2]);
if (activeBlock.getActivity() == null)
activeBlock.setActivity(RoutineActivity.patrol(new ArrayList<>()));
activeBlock.getActivity().getWaypoints().add(wp);
refreshWaypointList();
}));
delWp.setOnAction(e -> {
if (activeBlock.getActivity() != null && !activeBlock.getActivity().getWaypoints().isEmpty()) {
List<WorldPoint> wps = activeBlock.getActivity().getWaypoints();
wps.remove(wps.size() - 1);
refreshWaypointList();
}
});
dynamicBox.getChildren().addAll(styledLabel("Wegpunkte:", false), waypointList,
new HBox(6, addWp, delWp));
if (activeBlock.getActivity() != null && activeBlock.getActivity().getWaypoints() != null)
refreshWaypointList();
}
private void buildInteractableFields(String label) {
interactableCombo = itemCombo();
interactableCombo.setOnAction(e -> applyActivityToBlock());
dynamicBox.getChildren().addAll(styledLabel(label + ":", false), interactableCombo);
if (activeBlock.getActivity() != null && activeBlock.getActivity().getObjectUuid() != null)
selectItemCombo(interactableCombo, activeBlock.getActivity().getObjectUuid());
}
// ── Punkte picken ──────────────────────────────────────────────────────────
private void startPick(Consumer<float[]> callback) {
pendingPick = callback;
savedLayer = input.activeLayer;
input.activeLayer = SharedInput.LAYER_ROUTINE_EDITOR;
}
private Button pickButton(Consumer<float[]> callback) {
Button btn = new Button("Punkt wählen…");
btn.setOnAction(e -> startPick(xyz -> {
callback.accept(xyz);
applyActivityToBlock();
}));
return btn;
}
// ── Block-Daten pflegen ────────────────────────────────────────────────────
private void applyBlockTimes() {
if (activeBlock == null) return;
activeBlock.setStartHour(startSpin.getValue());
activeBlock.setEndHour(endSpin.getValue());
refreshBlockList();
refreshCoverage();
}
private void applyActivityToBlock() {
if (activeBlock == null || typeCombo.getValue() == null) return;
switch (typeCombo.getValue()) {
case "Sitzen" -> {
if (rbInteractable != null && rbInteractable.isSelected()) {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.sitInteractable(uuid, lbl));
} else {
WorldPoint pt = parsedPoint(pointLabel);
activeBlock.setActivity(pt != null ? RoutineActivity.sit(pt) : new RoutineActivity());
}
}
case "Stehen" -> {
WorldPoint pt = parsedPoint(pointLabel);
activeBlock.setActivity(pt != null ? RoutineActivity.stand(pt) : new RoutineActivity());
}
case "Reden" -> {
WorldPoint pt = parsedPoint(pointLabel);
String talkNpc = npcCombo != null ? npcCombo.getValue() : null;
activeBlock.setActivity(RoutineActivity.talk(pt, talkNpc));
}
case "Arbeiten" -> {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.work(uuid, lbl));
}
case "Schlafen" -> {
String uuid = selectedItemUuid(interactableCombo);
String lbl = selectedItemLabel(interactableCombo);
activeBlock.setActivity(RoutineActivity.sleep(uuid, lbl));
}
// PATROL is managed directly in buildPatrolFields
}
refreshBlockList();
}
// ── Routine-Operationen ────────────────────────────────────────────────────
private void addRoutine() {
TextInputDialog dlg = new TextInputDialog("Routine " + (routines.size() + 1));
dlg.setHeaderText("Name des Tagesablaufs:");
dlg.showAndWait().ifPresent(name -> {
if (name.isBlank()) return;
NpcRoutine r = new NpcRoutine(name.trim());
routines.add(r);
refreshRoutineList();
routineList.getSelectionModel().selectLast();
});
}
private void renameRoutine() {
if (activeRoutine == null) return;
TextInputDialog dlg = new TextInputDialog(activeRoutine.getName());
dlg.setHeaderText("Neuer Name:");
dlg.showAndWait().ifPresent(name -> {
if (!name.isBlank()) {
activeRoutine.setName(name.trim());
refreshRoutineList();
}
});
}
private void deleteRoutine() {
if (activeRoutine == null) return;
int idx = routines.indexOf(activeRoutine);
routines.remove(activeRoutine);
activeRoutine = null;
refreshRoutineList();
if (!routines.isEmpty())
routineList.getSelectionModel().select(Math.min(idx, routines.size() - 1));
}
private void addBlock() {
if (activeRoutine == null) return;
RoutineBlock b = new RoutineBlock(0, 1, null);
activeRoutine.getBlocks().add(b);
refreshBlockList();
blockList.getSelectionModel().selectLast();
}
private void deleteBlock() {
if (activeRoutine == null || activeBlock == null) return;
int idx = activeRoutine.getBlocks().indexOf(activeBlock);
activeRoutine.getBlocks().remove(activeBlock);
activeBlock = null;
refreshBlockList();
refreshBlockForm();
refreshCoverage();
if (!activeRoutine.getBlocks().isEmpty())
blockList.getSelectionModel().select(Math.min(idx, activeRoutine.getBlocks().size() - 1));
}
private void moveBlock(int delta) {
if (activeRoutine == null || activeBlock == null) return;
List<RoutineBlock> blocks = activeRoutine.getBlocks();
int idx = blocks.indexOf(activeBlock);
int newIdx = idx + delta;
if (newIdx < 0 || newIdx >= blocks.size()) return;
blocks.remove(idx);
blocks.add(newIdx, activeBlock);
refreshBlockList();
blockList.getSelectionModel().select(newIdx);
}
// ── Refresh-Methoden ───────────────────────────────────────────────────────
private void refreshRoutineList() {
int sel = routineList.getSelectionModel().getSelectedIndex();
routineList.getItems().clear();
for (int i = 0; i < routines.size(); i++) {
String label = routines.get(i).getName();
if (i == 0) label += " [Standard]";
routineList.getItems().add(label);
}
if (sel >= 0 && sel < routineList.getItems().size())
routineList.getSelectionModel().select(sel);
}
private void refreshBlockList() {
if (activeRoutine == null) {
blockList.getItems().clear();
return;
}
int sel = blockList.getSelectionModel().getSelectedIndex();
blockList.getItems().clear();
for (RoutineBlock b : activeRoutine.getBlocks())
blockList.getItems().add(b.displayLabel());
if (sel >= 0 && sel < blockList.getItems().size())
blockList.getSelectionModel().select(sel);
refreshCoverage();
}
private void refreshBlockForm() {
boolean hasBlock = (activeBlock != null);
if (blockFormBox != null) blockFormBox.setDisable(!hasBlock);
dynamicBox.getChildren().clear();
blockStatusLabel.setText(hasBlock ? "" : (activeRoutine != null ? "— Block wählen —" : "— Ablauf wählen —"));
if (!hasBlock) return;
startSpin.getValueFactory().setValue(activeBlock.getStartHour());
endSpin .getValueFactory().setValue(activeBlock.getEndHour());
typeCombo.setValue(activityTypeLabel(activeBlock.getActivity()));
refreshDynamicFields();
}
private void refreshCoverage() {
if (activeRoutine == null) { coverageLabel.setText(""); return; }
int h = activeRoutine.coveredHours();
String err = activeRoutine.validate();
if (err != null) coverageLabel.setTextFill(Color.SALMON);
else coverageLabel.setTextFill(Color.LIGHTGREEN);
coverageLabel.setText(h + "/24 Stunden" + (err != null ? "" + err : ""));
}
private void refreshWaypointList() {
if (waypointList == null || activeBlock == null || activeBlock.getActivity() == null) return;
waypointList.getItems().clear();
List<WorldPoint> wps = activeBlock.getActivity().getWaypoints();
if (wps == null) return;
for (int i = 0; i < wps.size(); i++) {
WorldPoint wp = wps.get(i);
waypointList.getItems().add((i + 1) + ". " + wp);
}
}
// ── Externe API ────────────────────────────────────────────────────────────
/** Lädt einen NPC (und seine Routinen) in die View. */
public void loadNpc(NPC npc) {
routines.clear();
if (npc.getRoutines() != null)
routines.addAll(npc.getRoutines());
activeRoutine = null;
activeBlock = null;
refreshRoutineList();
refreshBlockList();
refreshBlockForm();
}
/** Schreibt die aktuellen Routinen zurück in den NPC. */
public void exportToNpc(NPC npc) {
npc.setRoutines(new ArrayList<>(routines));
}
/** Muss aufgerufen werden, wenn die View geschlossen/versteckt wird. */
public void onHide() {
if (pendingPick != null) {
pendingPick = null;
input.activeLayer = savedLayer;
}
}
// ── Hilfsmethoden ──────────────────────────────────────────────────────────
private static Spinner<Integer> hourSpinner() {
Spinner<Integer> s = new Spinner<>(0, 23, 0);
s.setEditable(true);
s.setPrefWidth(68);
return s;
}
private Label pointLabel() {
Label l = new Label("— kein Punkt —");
l.setStyle("-fx-font-size: 11; -fx-text-fill: #aaa;");
return l;
}
private void setPointLabel(Label lbl, float[] xyz) {
lbl.setText(String.format("(%.1f, %.1f, %.1f)", xyz[0], xyz[1], xyz[2]));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}
private void setPointLabel(Label lbl, WorldPoint pt) {
if (pt == null) return;
lbl.setText(String.format("(%.1f, %.1f, %.1f)", pt.x, pt.y, pt.z));
lbl.setStyle("-fx-font-size: 11; -fx-text-fill: #ddd;");
}
private WorldPoint parsedPoint(Label lbl) {
if (lbl == null || lbl.getText().startsWith("")) return null;
String t = lbl.getText().replaceAll("[()]", "");
String[] p = t.split(",");
if (p.length != 3) return null;
try {
return new WorldPoint(Float.parseFloat(p[0].trim()),
Float.parseFloat(p[1].trim()),
Float.parseFloat(p[2].trim()));
} catch (NumberFormatException e) { return null; }
}
private ComboBox<String> itemCombo() {
ComboBox<String> c = new ComboBox<>();
c.setMaxWidth(Double.MAX_VALUE);
c.setPromptText("Objekt wählen…");
for (PlacedItem it : placedItems)
c.getItems().add(it.itemId() + " [" + it.uuid().substring(0, 8) + "…]");
return c;
}
private void selectItemCombo(ComboBox<String> c, String uuid) {
if (c == null || uuid == null) return;
c.getItems().stream()
.filter(s -> s.contains(uuid.substring(0, 8)))
.findFirst()
.ifPresent(c::setValue);
}
private String selectedItemUuid(ComboBox<String> c) {
if (c == null || c.getValue() == null) return null;
String v = c.getValue();
int s = v.indexOf('['), e = v.indexOf('…');
if (s < 0 || e < 0) return null;
String prefix = v.substring(s + 1, e);
return placedItems.stream()
.filter(it -> it.uuid().startsWith(prefix))
.findFirst()
.map(PlacedItem::uuid)
.orElse(null);
}
private String selectedItemLabel(ComboBox<String> c) {
if (c == null) return null;
String v = c.getValue();
if (v == null) return null;
int b = v.indexOf(" [");
return b > 0 ? v.substring(0, b) : v;
}
private String activityTypeLabel(RoutineActivity act) {
if (act == null || act.getType() == null) return "Stehen";
return switch (act.getType()) {
case SIT -> "Sitzen";
case STAND -> "Stehen";
case TALK -> "Reden";
case PATROL -> "Patrullieren";
case WORK -> "Arbeiten";
case SLEEP -> "Schlafen";
};
}
private static Label styledLabel(String text, boolean bold) {
Label l = new Label(text);
l.setStyle("-fx-text-fill: #ccc;" + (bold ? "-fx-font-weight: bold;" : ""));
return l;
}
private static class Spacer extends Region {
Spacer() { HBox.setHgrow(this, Priority.ALWAYS); }
}
}

View File

@@ -28,6 +28,7 @@ public class TriggerDialog extends Dialog<Trigger> {
private static final String TYPE_QUEST = "Quest starten";
private static final String TYPE_NPC = "NPC-Status ändern";
private static final String TYPE_FRACTION = "Fraktions-Status ändern";
private static final String TYPE_ROUTINE = "Routine ändern";
// Gemeinsam
private final ComboBox<String> typeCombo = new ComboBox<>();
@@ -45,6 +46,10 @@ public class TriggerDialog extends Dialog<Trigger> {
private TextField fractionIdField;
private ComboBox<Status> fractionStatusCombo;
// Routine ändern
private TextField routineNpcIdField;
private TextField routineNameField;
/** Öffnet den Dialog für einen neuen Trigger. */
public TriggerDialog() {
this(null);
@@ -56,7 +61,7 @@ public class TriggerDialog extends Dialog<Trigger> {
initModality(Modality.APPLICATION_MODAL);
setResizable(true);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION);
typeCombo.getItems().addAll(TYPE_QUEST, TYPE_NPC, TYPE_FRACTION, TYPE_ROUTINE);
typeCombo.setMaxWidth(Double.MAX_VALUE);
typeCombo.setOnAction(e -> rebuildDynamic(typeCombo.getValue()));
@@ -100,6 +105,7 @@ public class TriggerDialog extends Dialog<Trigger> {
case TYPE_QUEST -> buildQuestFields();
case TYPE_NPC -> buildNpcFields();
case TYPE_FRACTION -> buildFractionFields();
case TYPE_ROUTINE -> buildRoutineFields();
}
}
@@ -131,6 +137,16 @@ public class TriggerDialog extends Dialog<Trigger> {
);
}
private void buildRoutineFields() {
routineNpcIdField = field("Character-ID des NPCs");
routineNameField = field("Name der Routine");
dynamicArea.getChildren().addAll(
sectionTitle("Routine ändern"),
row("NPC-ID:", routineNpcIdField),
row("Routine-Name:", routineNameField)
);
}
// ── Trigger bauen ─────────────────────────────────────────────────────────
private Trigger buildTrigger() {
@@ -161,6 +177,12 @@ public class TriggerDialog extends Dialog<Trigger> {
if (fractionStatusCombo != null) f.setTargetStatus(fractionStatusCombo.getValue());
yield f;
}
case TYPE_ROUTINE -> {
ChangeRoutineTrigger r = new ChangeRoutineTrigger();
if (routineNpcIdField != null) r.setNpcId(routineNpcIdField.getText().trim());
if (routineNameField != null) r.setRoutineName(routineNameField.getText().trim());
yield r;
}
default -> null;
};
if (t != null) t.setRequiresChapter(chapterSpinner.getValue());
@@ -186,6 +208,12 @@ public class TriggerDialog extends Dialog<Trigger> {
fractionIdField.setText(f.getFractionId().toString());
if (fractionStatusCombo != null && f.getTargetStatus() != null)
fractionStatusCombo.setValue(f.getTargetStatus());
} else if (t instanceof ChangeRoutineTrigger r) {
typeCombo.setValue(TYPE_ROUTINE);
if (routineNpcIdField != null && r.getNpcId() != null)
routineNpcIdField.setText(r.getNpcId());
if (routineNameField != null && r.getRoutineName() != null)
routineNameField.setText(r.getRoutineName());
}
}

View File

@@ -105,6 +105,9 @@ public class TriggerListEditor extends VBox {
return "Fraktion-Status: "
+ (f.getFractionId() != null ? f.getFractionId().toString().substring(0, 8) + "" : "?")
+ "" + statusName(f.getTargetStatus()) + chapter;
if (t instanceof ChangeRoutineTrigger r)
return "Routine ändern: " + nullSafe(r.getNpcId())
+ " -> \"" + nullSafe(r.getRoutineName()) + "\"" + chapter;
return t.getClass().getSimpleName() + chapter;
}

View File

@@ -23,6 +23,8 @@
<logger name="com.jme3.scene.plugins.gltf" level="ERROR"/>
<!-- TangentBinormalGenerator warnt bei UV-Nähten und harten Kanten erwartet, kein Fehler -->
<logger name="com.jme3.util.TangentBinormalGenerator" level="ERROR"/>
<!-- Material warnt bei linear-color-space Texturen ohne passenden Parameter bekannt, kein Fehler -->
<logger name="com.jme3.material.Material" level="ERROR"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>