Commit vor dem Urlaub

This commit is contained in:
2026-07-26 17:36:09 +02:00
parent d7aeb890c9
commit 944b3fae34
37 changed files with 667 additions and 189 deletions

View File

@@ -74,6 +74,69 @@ public final class VoxelChunkIO {
} }
} }
// ── Pre-Bake-Backups ──────────────────────────────────────────────────────
/** Backup-Pfad der .blvc-Datei (wird vor dem Bake angelegt). */
public static Path getPreBakePath(int cx, int cy, int cz) {
return getPath(cx, cy, cz).resolveSibling(
getPath(cx, cy, cz).getFileName() + ".prebake");
}
public static boolean preBakeExists(int cx, int cy, int cz) {
return Files.exists(getPreBakePath(cx, cy, cz));
}
/** Kopiert die aktuelle .blvc-Datei als Pre-Bake-Backup. */
public static void savePreBake(int cx, int cy, int cz) throws IOException {
Path src = getPath(cx, cy, cz);
if (!Files.exists(src)) return;
Files.copy(src, getPreBakePath(cx, cy, cz), StandardCopyOption.REPLACE_EXISTING);
}
public static void deletePreBake(int cx, int cy, int cz) throws IOException {
Files.deleteIfExists(getPreBakePath(cx, cy, cz));
}
/** Stellt die .blvc-Datei aus dem Backup wieder her. */
public static void restorePreBake(int cx, int cy, int cz) throws IOException {
Path backup = getPreBakePath(cx, cy, cz);
if (!Files.exists(backup)) return;
Files.copy(backup, getPath(cx, cy, cz), StandardCopyOption.REPLACE_EXISTING);
}
/** true wenn mindestens ein Pre-Bake-Backup im Chunks-Verzeichnis existiert. */
public static boolean hasAnyPreBake() {
Path dir = ChunkTerrainIO.chunksDir();
if (!Files.isDirectory(dir)) return false;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "voxel_*.blvc.prebake")) {
return ds.iterator().hasNext();
} catch (IOException e) { return false; }
}
/** Alle Chunk-Koordinaten für die ein Pre-Bake-Backup existiert. */
public static List<int[]> listPreBakeCoords() {
List<int[]> result = new java.util.ArrayList<>();
Path dir = ChunkTerrainIO.chunksDir();
if (!Files.isDirectory(dir)) return result;
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "voxel_*.blvc.prebake")) {
for (Path p : ds) {
String name = p.getFileName().toString()
.replace("voxel_", "").replace(".blvc.prebake", "");
String[] parts = name.split("_");
if (parts.length != 3) continue;
try {
int cx = Integer.parseInt(parts[0]);
int cy = parts[1].startsWith("m")
? -Integer.parseInt(parts[1].substring(1))
: Integer.parseInt(parts[1]);
int cz = Integer.parseInt(parts[2]);
result.add(new int[]{cx, cy, cz});
} catch (NumberFormatException ignored) {}
}
} catch (IOException ignored) {}
return result;
}
/** /**
* Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis. * Liest alle vorhandenen VoxelChunks aus dem Chunks-Verzeichnis.
* Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert. * Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert.

View File

@@ -827,6 +827,11 @@ public class EditorApp extends Application {
vt.plateauTargetChanged = false; vt.plateauTargetChanged = false;
showToolParameters(toolPanel, input.activeTool); showToolParameters(toolPanel, input.activeTool);
} }
if (input.activeTool instanceof de.blight.editor.tool.VoxelTool vt2
&& vt2.modeChanged) {
vt2.modeChanged = false;
showToolParameters(toolPanel, input.activeTool);
}
// Neues JME-Image nach Viewport-Resize übernehmen // Neues JME-Image nach Viewport-Resize übernehmen
javafx.scene.image.WritableImage newImg = input.resizedImage.getAndSet(null); javafx.scene.image.WritableImage newImg = input.resizedImage.getAndSet(null);
@@ -3542,13 +3547,12 @@ public class EditorApp extends Application {
bakeBarLabel.setText("Starte..."); bakeBarLabel.setText("Starte...");
}); });
javafx.animation.Timeline poller = new javafx.animation.Timeline( javafx.animation.Timeline bakePoller = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> { new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> {
int total = input.bakeTotal; int total = input.bakeTotal;
int done = input.bakeDone; int done = input.bakeDone;
if (total > 0) { if (total > 0) {
double prog = (double) done / total; bakeBar.setProgress((double) done / total);
bakeBar.setProgress(prog);
bakeBarLabel.setText(done + " / " + total + " Chunks"); bakeBarLabel.setText(done + " / " + total + " Chunks");
} }
String msg = input.bakeStatusMsg; String msg = input.bakeStatusMsg;
@@ -3565,41 +3569,60 @@ public class EditorApp extends Application {
} }
}) })
); );
poller.setCycleCount(javafx.animation.Animation.INDEFINITE); bakePoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
poller.play(); bakePoller.play();
// ── Markierte Baked-Chunks löschen (Modus 6) ───────────────────── // ── Selektion: Löschen / Bake rückgängig (Modus "Baked selektieren") ──
Label markCountLabel = new Label("0 Chunks markiert"); Label selInfoLabel = new Label("Keine Auswahl");
markCountLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); selInfoLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;");
Label selHintLabel = new Label("LMK: Struktur wählen | RMK: Auswahl leeren");
selHintLabel.setStyle("-fx-font-size: 10; -fx-text-fill: #888;");
selHintLabel.setWrapText(true);
Button deleteMarkedBtn = new Button("Markierte Chunks löschen"); Button deleteBtn = new Button("Löschen");
deleteMarkedBtn.setMaxWidth(Double.MAX_VALUE); deleteBtn.setMaxWidth(Double.MAX_VALUE);
deleteMarkedBtn.setStyle("-fx-background-color: #ba4a4a; -fx-text-fill: white;"); deleteBtn.setStyle("-fx-background-color: #ba4a4a; -fx-text-fill: white;");
deleteMarkedBtn.setDisable(true); deleteBtn.setDisable(true);
deleteMarkedBtn.setOnAction(e -> { deleteBtn.setOnAction(e -> {
int n = input.markedDeleteBakedKeys.size();
javafx.scene.control.Alert confirm = new javafx.scene.control.Alert(
javafx.scene.control.Alert.AlertType.CONFIRMATION,
n + " Chunk" + (n != 1 ? "s" : "") + " unwiderruflich löschen?",
javafx.scene.control.ButtonType.YES,
javafx.scene.control.ButtonType.NO);
confirm.setTitle("Gebackene Chunks löschen");
confirm.setHeaderText(null);
confirm.initOwner(primaryStage);
confirm.showAndWait().ifPresent(btn -> {
if (btn == javafx.scene.control.ButtonType.YES) {
input.deleteMarkedBakedRequested = true; input.deleteMarkedBakedRequested = true;
deleteMarkedBtn.setDisable(true); }
});
}); });
Button clearMarkBtn = new Button("Markierung leeren"); Button revertBtn = new Button("Bake rückgängig");
clearMarkBtn.setMaxWidth(Double.MAX_VALUE); revertBtn.setMaxWidth(Double.MAX_VALUE);
clearMarkBtn.setDisable(true); revertBtn.setStyle("-fx-background-color: #7a5a2a; -fx-text-fill: white;");
clearMarkBtn.setOnAction(e -> input.clearMarkedBakedRequested = true); revertBtn.setDisable(true);
revertBtn.setOnAction(e -> input.revertMarkedBakedRequested = true);
// Poller: Anzahl markierter Chunks + Bake-Status aktualisieren javafx.animation.Timeline selPoller = new javafx.animation.Timeline(
javafx.animation.Timeline markPoller = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> { new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> {
int n = input.markedDeleteBakedKeys.size(); int n = input.markedDeleteBakedKeys.size();
markCountLabel.setText(n + " Chunk" + (n != 1 ? "s" : "") + " markiert"); boolean has = n > 0;
deleteMarkedBtn.setDisable(n == 0); selInfoLabel.setText(has
clearMarkBtn.setDisable(n == 0); ? n + " Chunk" + (n != 1 ? "s" : "") + " ausgewählt"
: "Keine Auswahl");
deleteBtn.setDisable(!has);
revertBtn.setDisable(!has);
}) })
); );
markPoller.setCycleCount(javafx.animation.Animation.INDEFINITE); selPoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
markPoller.play(); selPoller.play();
panel.getChildren().addAll(new Separator(), bakeBtn, bakeBar, bakeBarLabel, bakeStatus, panel.getChildren().addAll(new Separator(), bakeBtn,
new Separator(), markCountLabel, deleteMarkedBtn, clearMarkBtn); bakeBar, bakeBarLabel, bakeStatus,
new Separator(), selInfoLabel, selHintLabel, deleteBtn, revertBtn);
} }
} }
@@ -7660,6 +7683,7 @@ public class EditorApp extends Application {
} else if (e.getButton() == MouseButton.SECONDARY && !bothDown) { } else if (e.getButton() == MouseButton.SECONDARY && !bothDown) {
input.objectClickQueue.offer( input.objectClickQueue.offer(
new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), true, false)); new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), true, false));
objDragPrevX = e.getX(); objDragPrevY = e.getY();
} }
return; return;
} }
@@ -7731,6 +7755,12 @@ public class EditorApp extends Application {
float dy = (float)(e.getY() - objDragPrevY); float dy = (float)(e.getY() - objDragPrevY);
input.objectDragQueue.offer(new SharedInput.ObjectDrag(dx, dy)); input.objectDragQueue.offer(new SharedInput.ObjectDrag(dx, dy));
objDragPrevX = e.getX(); objDragPrevY = e.getY(); objDragPrevX = e.getX(); objDragPrevY = e.getY();
} else if (e.isSecondaryButtonDown() && !e.isPrimaryButtonDown()
&& !e.isMiddleButtonDown()
&& input.activeLayer == SharedInput.LAYER_OBJECTS) {
float dx = (float)(e.getX() - objDragPrevX);
input.objectRotateDragQueue.offer(new SharedInput.ObjectRotateDrag(dx));
objDragPrevX = e.getX(); objDragPrevY = e.getY();
} }
return; return;
} }

View File

@@ -262,6 +262,10 @@ public class SharedInput {
public record ObjectDrag(float dx, float dy) {} public record ObjectDrag(float dx, float dy) {}
public final ConcurrentLinkedQueue<ObjectDrag> objectDragQueue = new ConcurrentLinkedQueue<>(); public final ConcurrentLinkedQueue<ObjectDrag> objectDragQueue = new ConcurrentLinkedQueue<>();
/** RMB-Drag im Platzierungsmodus: dreht das Vorschau-Objekt um die Y-Achse. dx = Pixel-Delta. */
public record ObjectRotateDrag(float dx) {}
public final ConcurrentLinkedQueue<ObjectRotateDrag> objectRotateDragQueue = new ConcurrentLinkedQueue<>();
/** Wird von JME3 gesetzt wenn ein neues Objekt oder eine neue Selektion vorliegt. */ /** Wird von JME3 gesetzt wenn ein neues Objekt oder eine neue Selektion vorliegt. */
// Format: "1|modelPath|solid|x|y|z|rotX|rotY|rotZ|scale|texPath" (1 Objekt) // Format: "1|modelPath|solid|x|y|z|rotX|rotY|rotZ|scale|texPath" (1 Objekt)
// "N" (N≥2 Objekte ausgewählt) // "N" (N≥2 Objekte ausgewählt)
@@ -782,15 +786,17 @@ public class SharedInput {
public volatile int blurIterDone = 0; public volatile int blurIterDone = 0;
/** /**
* Chunks, die per Brush zum Löschen markiert wurden (chunkKey-kodiert). * Per BFS selektierte gebackene Chunks (chunkKey-kodiert).
* Thread-sicher; JME schreibt, JFX liest (nur size() für Anzeige). * Thread-sicher; JME schreibt, JFX liest (nur size() für Anzeige).
*/ */
public final java.util.Set<Long> markedDeleteBakedKeys = public final java.util.Set<Long> markedDeleteBakedKeys =
java.util.concurrent.ConcurrentHashMap.newKeySet(); java.util.concurrent.ConcurrentHashMap.newKeySet();
/** JFX → JME: alle markierten Chunks löschen. */ /** JME/JFX → JME: Wireframe-Visualisierung der Selektion neu berechnen. */
public volatile boolean markedBakedSelectionDirty = false;
/** JFX → JME: selektierte Chunks löschen. */
public volatile boolean deleteMarkedBakedRequested = false; public volatile boolean deleteMarkedBakedRequested = false;
/** JFX → JME: Markierung ohne Löschen leeren. */ /** JFX → JME: selektierte Chunks aus Pre-Bake-Backup wiederherstellen. */
public volatile boolean clearMarkedBakedRequested = false; public volatile boolean revertMarkedBakedRequested = false;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */ /** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
public volatile int voxelFlatSlot = -1; public volatile int voxelFlatSlot = -1;

View File

@@ -216,22 +216,43 @@ public class GrassVertexState extends BaseAppState {
if (terrain == null) continue; if (terrain == null) continue;
float jmeX = (float) (edit.screenX() * input.viewportScaleX); float jmeX = (float) (edit.screenX() * input.viewportScaleX);
float jmeY = (float) (edit.screenY() * input.viewportScaleY); float jmeY = (float) (edit.screenY() * input.viewportScaleY);
Vector3f hit = raycastTerrain(jmeX, jmeY, getApplication().getCamera()); Vector3f hit = raycastSurface(jmeX, jmeY, getApplication().getCamera());
if (hit == null) continue; if (hit == null) continue;
if (edit.action() > 0) addBlades(hit); if (edit.action() > 0) addBlades(hit);
else removeBlades(hit); else removeBlades(hit);
} }
} }
private Vector3f raycastTerrain(float screenX, float screenY, com.jme3.renderer.Camera cam) { private Vector3f raycastSurface(float screenX, float screenY, com.jme3.renderer.Camera cam) {
float flippedY = cam.getHeight() - screenY; float flippedY = cam.getHeight() - screenY;
Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f); Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f);
Vector3f direction = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f) Vector3f direction = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f)
.subtractLocal(origin).normalizeLocal(); .subtractLocal(origin).normalizeLocal();
Ray ray = new Ray(origin, direction); Ray ray = new Ray(origin, direction);
CollisionResults res = new CollisionResults(); CollisionResults res = new CollisionResults();
terrain.collideWith(ray, res); terrain.collideWith(ray, res);
return res.size() == 0 ? null : res.getClosestCollision().getContactPoint(); Vector3f best = res.size() > 0 ? res.getClosestCollision().getContactPoint() : null;
float bestDistSq = best != null ? origin.distanceSquared(best) : Float.MAX_VALUE;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
Vector3f vp = ves.raycastVoxelGeometry(ray);
if (vp != null) {
float d = origin.distanceSquared(vp);
if (d < bestDistSq) { best = vp; bestDistSq = d; }
}
}
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Vector3f sp = smes.raycastGeometry(ray);
if (sp != null) {
float d = origin.distanceSquared(sp);
if (d < bestDistSq) { best = sp; }
}
}
return best;
} }
private final Random rng = new Random(); private final Random rng = new Random();

View File

@@ -253,16 +253,40 @@ public class PlacedObjectState extends BaseAppState {
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f); Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
Ray ray = new Ray(near, far.subtract(near).normalizeLocal()); Ray ray = new Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults(); Vector3f contact = raycastSurface(ray);
terrain.collideWith(ray, hits); if (contact == null) continue;
if (hits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint();
float radius = (float) input.grassTool.brushRadius.getValue(); float radius = (float) input.grassTool.brushRadius.getValue();
if (edit.action() > 0) paintGrass(contact.x, contact.z, radius); if (edit.action() > 0) paintGrass(contact.x, contact.z, radius);
else eraseGrass(contact.x, contact.z, radius); else eraseGrass(contact.x, contact.z, radius);
} }
} }
private Vector3f raycastSurface(Ray ray) {
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
Vector3f best = hits.size() > 0 ? hits.getClosestCollision().getContactPoint() : null;
float bestDistSq = best != null ? ray.getOrigin().distanceSquared(best) : Float.MAX_VALUE;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
Vector3f vp = ves.raycastVoxelGeometry(ray);
if (vp != null) {
float d = ray.getOrigin().distanceSquared(vp);
if (d < bestDistSq) { best = vp; bestDistSq = d; }
}
}
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Vector3f sp = smes.raycastGeometry(ray);
if (sp != null) {
float d = ray.getOrigin().distanceSquared(sp);
if (d < bestDistSq) { best = sp; }
}
}
return best;
}
private void paintGrass(float cx, float cz, float radius) { private void paintGrass(float cx, float cz, float radius) {
int n = Math.max(1, (int) input.grassTool.density.getValue()); int n = Math.max(1, (int) input.grassTool.density.getValue());
float baseH = (float) input.grassTool.grassHeight.getValue(); float baseH = (float) input.grassTool.grassHeight.getValue();

View File

@@ -107,6 +107,9 @@ public class SceneObjectState extends BaseAppState {
private float currentRandomRotY = 0f; private float currentRandomRotY = 0f;
private final java.util.Random rng = new java.util.Random(); private final java.util.Random rng = new java.util.Random();
/** Manuell per RMB-Drag eingestellte Y-Rotation der Vorschau (radians). */
private float previewRotY = 0f;
private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt) private Node subOverlay = null; // Sub-Selektion-Highlight (Polygon/Kante/Punkt)
private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi) private Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi)
@@ -355,6 +358,12 @@ public class SceneObjectState extends BaseAppState {
folderTreeAssetPaths.clear(); folderTreeAssetPaths.clear();
} }
// RMB-Drag: Vorschau-Rotation anpassen
SharedInput.ObjectRotateDrag rotateDrag;
while ((rotateDrag = input.objectRotateDragQueue.poll()) != null) {
previewRotY += rotateDrag.dx() * DEG_PER_PX * com.jme3.math.FastMath.DEG_TO_RAD;
}
updatePreview(); updatePreview();
if (input.reloadPlacedModels) { if (input.reloadPlacedModels) {
@@ -500,13 +509,10 @@ public class SceneObjectState extends BaseAppState {
if (snapped != null) pt = snapped; if (snapped != null) pt = snapped;
} }
previewNode.setLocalTranslation(pt.x, pt.y, pt.z); previewNode.setLocalTranslation(pt.x, pt.y, pt.z);
if (input.treeFolderPath != null) { float totalRotY = (input.treeFolderPath != null ? currentRandomRotY : 0f) + previewRotY;
com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion();
rot.fromAngleAxis(currentRandomRotY, com.jme3.math.Vector3f.UNIT_Y); rot.fromAngleAxis(totalRotY, com.jme3.math.Vector3f.UNIT_Y);
previewNode.setLocalRotation(rot); previewNode.setLocalRotation(rot);
} else {
previewNode.setLocalRotation(com.jme3.math.Quaternion.IDENTITY);
}
previewNode.setCullHint(Spatial.CullHint.Inherit); previewNode.setCullHint(Spatial.CullHint.Inherit);
} }
@@ -567,10 +573,11 @@ public class SceneObjectState extends BaseAppState {
// ── Klick-Handling ──────────────────────────────────────────────────────── // ── Klick-Handling ────────────────────────────────────────────────────────
private void handleClick(SharedInput.ObjectClick click) { private void handleClick(SharedInput.ObjectClick click) {
// Rechtsklick: re-randomisiert im Baum-Ordner-Modus, sonst für Kamera // Rechtsklick: re-randomisiert im Baum-Ordner-Modus; RMB-Drag dreht das Objekt
if (click.rightButton()) { if (click.rightButton()) {
if (input.treeFolderPath != null && !folderTreeAssetPaths.isEmpty()) { if (input.treeFolderPath != null && !folderTreeAssetPaths.isEmpty()) {
applyRandomTree(); applyRandomTree();
previewRotY = 0f;
} }
return; return;
} }
@@ -635,10 +642,12 @@ public class SceneObjectState extends BaseAppState {
if (snapped != null) pt = snapped; if (snapped != null) pt = snapped;
} }
previewNode.setCullHint(Spatial.CullHint.Always); previewNode.setCullHint(Spatial.CullHint.Always);
float rotY = input.treeFolderPath != null ? currentRandomRotY : 0f; float rotY = (input.treeFolderPath != null ? currentRandomRotY : 0f) + previewRotY;
placeObject(modelPath, pt.x, pt.z, pt.y, rotY); placeObject(modelPath, pt.x, pt.z, pt.y, rotY);
// After placing in folder mode, re-randomize for the next placement if (input.treeFolderPath != null) {
if (input.treeFolderPath != null) applyRandomTree(); applyRandomTree();
previewRotY = 0f;
}
} }
// ── Objekt platzieren ──────────────────────────────────────────────────── // ── Objekt platzieren ────────────────────────────────────────────────────

View File

@@ -3,6 +3,7 @@ package de.blight.editor.state;
import com.jme3.app.Application; import com.jme3.app.Application;
import com.jme3.app.SimpleApplication; import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState; import com.jme3.app.state.BaseAppState;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults; import com.jme3.collision.CollisionResults;
import com.jme3.export.binary.BinaryImporter; import com.jme3.export.binary.BinaryImporter;
import com.jme3.material.Material; import com.jme3.material.Material;
@@ -68,6 +69,8 @@ public class SculptedMeshEditorState extends BaseAppState {
private long selectedKey = -1L; private long selectedKey = -1L;
private Material normalMat = null; private Material normalMat = null;
private Material highlightMat = null; private Material highlightMat = null;
private Material selectionWireframeMat = null;
private final Set<Long> appliedSelectionKeys = new HashSet<>();
// ── innere Klasse ───────────────────────────────────────────────────────── // ── innere Klasse ─────────────────────────────────────────────────────────
@@ -120,6 +123,10 @@ public class SculptedMeshEditorState extends BaseAppState {
highlightMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); highlightMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
highlightMat.setColor("Color", new ColorRGBA(1f, 0.65f, 0f, 1f)); highlightMat.setColor("Color", new ColorRGBA(1f, 0.65f, 0f, 1f));
selectionWireframeMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
selectionWireframeMat.setColor("Color", new ColorRGBA(0.1f, 1f, 0.45f, 1f));
selectionWireframeMat.getAdditionalRenderState().setWireframe(true);
brushIndicator = buildBrushIndicator(); brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator); app.getRootNode().attachChild(brushIndicator);
@@ -129,11 +136,45 @@ public class SculptedMeshEditorState extends BaseAppState {
@Override @Override
protected void cleanup(Application application) { protected void cleanup(Application application) {
saveAllDirty(); saveAllDirty();
// Wireframe-Material zurücksetzen
for (long key : appliedSelectionKeys) {
EditableMesh em = meshes.get(key);
if (em != null && em.geo != null && normalMat != null) {
em.geo.setMaterial(normalMat);
}
}
appliedSelectionKeys.clear();
sculptRoot.removeFromParent(); sculptRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent(); if (brushIndicator != null) brushIndicator.removeFromParent();
meshes.clear(); meshes.clear();
} }
private void updateSelectionWireframe() {
Set<Long> current = new HashSet<>(input.markedDeleteBakedKeys);
// Wireframe von nicht mehr selektierten Chunks entfernen
Iterator<Long> it = appliedSelectionKeys.iterator();
while (it.hasNext()) {
long key = it.next();
if (!current.contains(key)) {
EditableMesh em = meshes.get(key);
if (em != null && em.geo != null) {
em.geo.setMaterial(key == selectedKey ? highlightMat : normalMat);
}
it.remove();
}
}
// Wireframe auf neu selektierte Chunks anwenden
for (long key : current) {
if (!appliedSelectionKeys.contains(key)) {
EditableMesh em = meshes.get(key);
if (em != null && em.geo != null) {
em.geo.setMaterial(selectionWireframeMat);
appliedSelectionKeys.add(key);
}
}
}
}
@Override protected void onEnable() {} @Override protected void onEnable() {}
@Override protected void onDisable() {} @Override protected void onDisable() {}
@@ -152,6 +193,12 @@ public class SculptedMeshEditorState extends BaseAppState {
if (input.sculptRescanNeeded) { if (input.sculptRescanNeeded) {
input.sculptRescanNeeded = false; input.sculptRescanNeeded = false;
rescanBakedChunks(); rescanBakedChunks();
input.markedBakedSelectionDirty = true;
}
if (input.markedBakedSelectionDirty) {
input.markedBakedSelectionDirty = false;
updateSelectionWireframe();
} }
updateBrushIndicator(); updateBrushIndicator();
@@ -756,4 +803,24 @@ public class SculptedMeshEditorState extends BaseAppState {
private static long chunkKey(int cx, int cy, int cz) { private static long chunkKey(int cx, int cy, int cz) {
return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32); return ((long)(cx & 0xFFFF)) | (((long)(cy & 0xFFFF)) << 16) | (((long)(cz & 0xFFFF)) << 32);
} }
/** Raycast gegen alle gebackenen Voxel-Meshes; gibt den nächsten Treffpunkt oder null zurück. */
public com.jme3.math.Vector3f raycastGeometry(com.jme3.math.Ray ray) {
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
return cr.size() > 0 ? cr.getClosestCollision().getContactPoint() : null;
}
/** Wie raycastGeometry, gibt aber zusätzlich die Flächennormale zurück (Index 0 = Punkt, 1 = Normale). */
public com.jme3.math.Vector3f[] raycastGeometryWithNormal(com.jme3.math.Ray ray) {
if (sculptRoot == null) return null;
CollisionResults cr = new CollisionResults();
sculptRoot.collideWith(ray, cr);
if (cr.size() == 0) return null;
CollisionResult best = cr.getClosestCollision();
com.jme3.math.Vector3f norm = best.getContactNormal();
if (norm == null) norm = new com.jme3.math.Vector3f(0, 1, 0);
else norm = norm.normalize();
return new com.jme3.math.Vector3f[]{best.getContactPoint(), norm};
}
} }

View File

@@ -1117,10 +1117,10 @@ public class TerrainEditorState extends BaseAppState {
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal()); com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal());
CollisionResults hits = new CollisionResults(); CollisionResults texHits = new CollisionResults();
terrain.collideWith(ray, hits); terrain.collideWith(ray, texHits);
if (hits.size() == 0) continue; if (texHits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint(); Vector3f contact = texHits.getClosestCollision().getContactPoint();
int selIdx = input.textureTool.textureIndex.getSelectedIndex(); int selIdx = input.textureTool.textureIndex.getSelectedIndex();
float str = (float) input.textureTool.brushStrength.getValue(); float str = (float) input.textureTool.brushStrength.getValue();
@@ -1146,6 +1146,36 @@ public class TerrainEditorState extends BaseAppState {
/** Gibt den TerrainQuad-Node zurück (z.B. für Voxel-Raycasts). */ /** Gibt den TerrainQuad-Node zurück (z.B. für Voxel-Raycasts). */
public TerrainQuad getTerrainNode() { return terrain; } public TerrainQuad getTerrainNode() { return terrain; }
/**
* Raycast gegen Basis-Terrain, ungebackenes Voxel-Terrain und gebackene Sculpted-Meshes.
* Gibt den zur Kamera nächstgelegenen Treffer zurück, oder null wenn keines getroffen wurde.
*/
private Vector3f raycastSurface(com.jme3.math.Ray ray) {
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
Vector3f best = hits.size() > 0 ? hits.getClosestCollision().getContactPoint() : null;
float bestDistSq = best != null ? ray.getOrigin().distanceSquared(best) : Float.MAX_VALUE;
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
Vector3f vp = ves.raycastVoxelGeometry(ray);
if (vp != null) {
float d = ray.getOrigin().distanceSquared(vp);
if (d < bestDistSq) { best = vp; bestDistSq = d; }
}
}
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Vector3f sp = smes.raycastGeometry(ray);
if (sp != null) {
float d = ray.getOrigin().distanceSquared(sp);
if (d < bestDistSq) { best = sp; }
}
}
return best;
}
/** Gibt die Terrain-Höhe (Welt-Y) an der angegebenen Welt-XZ-Position zurück. */ /** Gibt die Terrain-Höhe (Welt-Y) an der angegebenen Welt-XZ-Position zurück. */
public float getTerrainHeightAt(float worldX, float worldZ) { public float getTerrainHeightAt(float worldX, float worldZ) {
if (terrain == null) return 0f; if (terrain == null) return 0f;
@@ -1347,26 +1377,20 @@ public class TerrainEditorState extends BaseAppState {
float brushRadius = 0f; float brushRadius = 0f;
if (layer == 0 || layer == 4) { if (layer == 0 || layer == 4) {
CollisionResults hits = new CollisionResults(); contactPoint = raycastSurface(ray);
terrain.collideWith(ray, hits); if (contactPoint != null) {
if (hits.size() > 0) {
contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (layer == 0) brushRadius = (layer == 0)
? (float) input.heightTool.brushRadius.getValue() ? (float) input.heightTool.brushRadius.getValue()
: (float) input.textureTool.brushRadius.getValue(); : (float) input.textureTool.brushRadius.getValue();
} }
} else if (layer == 3) { } else if (layer == 3) {
CollisionResults hits = new CollisionResults(); contactPoint = raycastSurface(ray);
terrain.collideWith(ray, hits); if (contactPoint != null) {
if (hits.size() > 0) {
contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (float) input.grassTool.brushRadius.getValue(); brushRadius = (float) input.grassTool.brushRadius.getValue();
} }
} else if (layer == SharedInput.LAYER_GRASS_VERTEX) { } else if (layer == SharedInput.LAYER_GRASS_VERTEX) {
CollisionResults hits = new CollisionResults(); contactPoint = raycastSurface(ray);
terrain.collideWith(ray, hits); if (contactPoint != null) {
if (hits.size() > 0) {
contactPoint = hits.getClosestCollision().getContactPoint();
brushRadius = (float) input.grassVertexTool.brushRadius.getValue(); brushRadius = (float) input.grassVertexTool.brushRadius.getValue();
} }
} }
@@ -1410,20 +1434,16 @@ public class TerrainEditorState extends BaseAppState {
int mode = input.heightTool.mode.getSelectedIndex(); int mode = input.heightTool.mode.getSelectedIndex();
// Plateau-RMB: vor dem Terrain-Raycast prüfen, damit Kliff-Flächen funktionieren
if (mode == HeightTool.MODE_PLATEAU && edit.action() < 0) {
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
Vector3f contact = hits.size() > 0 ? hits.getClosestCollision().getContactPoint() : null;
sampleAndSetPlateauHeight(ray, contact);
continue;
}
CollisionResults hits = new CollisionResults(); CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits); terrain.collideWith(ray, hits);
if (hits.size() == 0) continue; if (hits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint(); Vector3f contact = hits.getClosestCollision().getContactPoint();
// Plateau-RMB: Höhe von der sichtbaren Oberfläche samplen (Basis + Voxel)
if (mode == HeightTool.MODE_PLATEAU && edit.action() < 0) {
sampleAndSetPlateauHeight(ray, contact);
continue;
}
if (mode == HeightTool.MODE_SMOOTH) { if (mode == HeightTool.MODE_SMOOTH) {
if (edit.action() > 0) { if (edit.action() > 0) {
slopeHeight(contact); slopeHeight(contact);

View File

@@ -131,13 +131,6 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator; private Geometry brushIndicator;
// ── Overlay für markierte Baked-Chunks ────────────────────────────────────
/** Root-Node für rote Chunk-Markierungen im MODE_DELETE_BAKED. */
private Node markedChunkOverlayRoot;
/** key → zugehörige Overlay-Geometrie. */
private final Map<Long, Geometry> markedOverlays = new HashMap<>();
// ── Basis-Terrain-Referenzebene (y = -10) ──────────────────────────────── // ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */ /** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */
@@ -216,11 +209,6 @@ public class VoxelEditorState extends BaseAppState {
brushIndicator = buildBrushIndicator(); brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator); app.getRootNode().attachChild(brushIndicator);
// Overlay-Root für markierte Baked-Chunks
markedChunkOverlayRoot = new Node("markedBakedChunks");
markedChunkOverlayRoot.setCullHint(Spatial.CullHint.Always);
app.getRootNode().attachChild(markedChunkOverlayRoot);
// Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter // Basis-Terrain-Referenzebene bei y = -10 + Chunk-Gitter
basePlaneNode = new Node("voxelBasePlaneNode"); basePlaneNode = new Node("voxelBasePlaneNode");
basePlane = buildBasePlane(); basePlane = buildBasePlane();
@@ -245,7 +233,6 @@ public class VoxelEditorState extends BaseAppState {
voxelRoot.removeFromParent(); voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent(); if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent(); if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (markedChunkOverlayRoot != null) markedChunkOverlayRoot.removeFromParent();
if (wireframeActive) applyWireframe(false); if (wireframeActive) applyWireframe(false);
nodes.clear(); nodes.clear();
chunks.clear(); chunks.clear();
@@ -290,12 +277,12 @@ public class VoxelEditorState extends BaseAppState {
}); });
} }
// Markierte Baked-Chunks löschen // Selektierte Baked-Chunks löschen
if (input.deleteMarkedBakedRequested) { if (input.deleteMarkedBakedRequested) {
input.deleteMarkedBakedRequested = false; input.deleteMarkedBakedRequested = false;
Set<Long> toDelete = new HashSet<>(input.markedDeleteBakedKeys); Set<Long> toDelete = new HashSet<>(input.markedDeleteBakedKeys);
input.markedDeleteBakedKeys.clear(); input.markedDeleteBakedKeys.clear();
clearMarkedOverlays(); input.markedBakedSelectionDirty = true;
executor.submit(() -> { executor.submit(() -> {
try { try {
deleteMarkedBaked(toDelete); deleteMarkedBaked(toDelete);
@@ -306,11 +293,20 @@ public class VoxelEditorState extends BaseAppState {
}); });
} }
// Markierung leeren // Selektierte Chunks aus Pre-Bake-Backup wiederherstellen
if (input.clearMarkedBakedRequested) { if (input.revertMarkedBakedRequested) {
input.clearMarkedBakedRequested = false; input.revertMarkedBakedRequested = false;
Set<Long> toRevert = new HashSet<>(input.markedDeleteBakedKeys);
input.markedDeleteBakedKeys.clear(); input.markedDeleteBakedKeys.clear();
clearMarkedOverlays(); input.markedBakedSelectionDirty = true;
executor.submit(() -> {
try {
revertMarkedBaked(toRevert);
} catch (Throwable e) {
log.error("Markierter-Revert fehlgeschlagen: {}", e.getMessage(), e);
input.bakeStatusMsg = "Revert FEHLER: " + e.getMessage();
}
});
} }
// Voxel-Texturen (Slot/Normal-Map) aktualisiert? // Voxel-Texturen (Slot/Normal-Map) aktualisiert?
@@ -630,6 +626,22 @@ public class VoxelEditorState extends BaseAppState {
results.clear(); results.clear();
} }
// Gebackene Voxel-Meshes (SculptedMeshEditorState) ebenfalls treffen,
// damit Werkzeuge auch auf gebackenem Terrain funktionieren.
SculptedMeshEditorState smes =
getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
Vector3f[] hit = smes.raycastGeometryWithNormal(ray);
if (hit != null) {
float d = origin.distance(hit[0]);
if (d < bestDist) {
bestDist = d;
bestPos = hit[0];
bestNorm = hit[1];
}
}
}
// Basis-Terrain-Referenzebene (y = -10) als Raycast-Ziel // Basis-Terrain-Referenzebene (y = -10) als Raycast-Ziel
if (basePlaneNode != null && basePlane != null if (basePlaneNode != null && basePlane != null
&& basePlane.getCullHint() != Spatial.CullHint.Always) { && basePlane.getCullHint() != Spatial.CullHint.Always) {
@@ -674,9 +686,17 @@ public class VoxelEditorState extends BaseAppState {
int modeIdx = input.voxelTool.mode.getSelectedIndex(); int modeIdx = input.voxelTool.mode.getSelectedIndex();
boolean isHorizontal = input.voxelTool.horizontal; boolean isHorizontal = input.voxelTool.horizontal;
// Baked-Chunk Markierungs-Modus: keine Voxel-Bearbeitung, nur Selektion // Baked-Selektierungs-Modus: keine Voxel-Bearbeitung, nur BFS-Selektion.
// Nach der Aktion automatisch auf Sinus (Modus 0) zurückschalten.
if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_DELETE_BAKED) { if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_DELETE_BAKED) {
toggleBakedChunkMark(hit.pos.x, hit.pos.z, radius, action >= 0); if (action >= 0) {
markConnectedBakedStructure(hit.pos.x, hit.pos.y, hit.pos.z);
} else {
input.markedDeleteBakedKeys.clear();
input.markedBakedSelectionDirty = true;
}
input.voxelTool.mode.setSelectedIndex(de.blight.editor.tool.VoxelTool.MODE_SINUS);
input.voxelTool.modeChanged = true;
return; return;
} }
@@ -728,6 +748,16 @@ public class VoxelEditorState extends BaseAppState {
int cyMin = VoxelChunk.worldYToCy(wy - worldExtent); int cyMin = VoxelChunk.worldYToCy(wy - worldExtent);
int cyMax = VoxelChunk.worldYToCy(wy + worldExtent); int cyMax = VoxelChunk.worldYToCy(wy + worldExtent);
// Für Spalten-Modi: cy-Bereich auf den Anker-Chunk ausdehnen.
// Wenn der Nutzer auf die Basis-Ebene (y=-10) klickt, liegt der Terrain-Ankerpunkt
// (terrainH ≥ 0) in einem anderen cy als der Hit-Punkt → Voxel würden sonst nie gesetzt.
if (isColumn && !isHorizontal && !isCave) {
float anchorY = Math.max(terrainH(wx, wz), wy);
int anchorCy = VoxelChunk.worldYToCy(anchorY);
cyMin = Math.min(cyMin, anchorCy);
cyMax = Math.max(cyMax, anchorCy);
}
// Smooth-Modus: Slope-Parameter vorab berechnen (nur vertikal) // Smooth-Modus: Slope-Parameter vorab berechnen (nur vertikal)
float[] slopeParams = null; float[] slopeParams = null;
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) { if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
@@ -779,7 +809,7 @@ public class VoxelEditorState extends BaseAppState {
applySlopeColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams); applySlopeColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
} }
} else { } else {
applyColumnBrush(chunk, cx, cy, cz, wx, wz, radius, strength, modeIdx, lower); applyColumnBrush(chunk, cx, cy, cz, wx, wz, wy, radius, strength, modeIdx, lower);
} }
// Node erst anlegen wenn tatsächlich Daten vorhanden // Node erst anlegen wenn tatsächlich Daten vorhanden
@@ -873,6 +903,7 @@ public class VoxelEditorState extends BaseAppState {
*/ */
private void applyColumnBrush(VoxelChunk chunk, int cx, int cy, int cz, private void applyColumnBrush(VoxelChunk chunk, int cx, int cy, int cz,
float brushWX, float brushWZ, float brushWX, float brushWZ,
float brushHitWY,
float radius, float strength, float radius, float strength,
int mode, boolean lower) { int mode, boolean lower) {
float lxC = VoxelChunk.worldXToLocal(brushWX, cx); float lxC = VoxelChunk.worldXToLocal(brushWX, cx);
@@ -898,9 +929,8 @@ public class VoxelEditorState extends BaseAppState {
float t = (float) Math.sqrt(d2) / radius; float t = (float) Math.sqrt(d2) / radius;
float falloff = computeFalloff(mode, t); float falloff = computeFalloff(mode, t);
// Smooth/Sinus: Kanten bekommen weniger Schritt → richtiges Profil // Mindestens 1, damit Kanten-Spalten nicht komplett übersprungen werden
int colStep = (int)(stepBase * falloff); int colStep = Math.max(1, (int)(stepBase * falloff));
if (colStep < 1) continue;
if (!lower) { if (!lower) {
// Höchsten Solid-Voxel in dieser Spalte suchen // Höchsten Solid-Voxel in dieser Spalte suchen
@@ -909,15 +939,17 @@ public class VoxelEditorState extends BaseAppState {
if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; } if (chunk.getDensity(lx, ly, lz) > 0) { currentTop = ly; break; }
} }
if (currentTop < 0) { if (currentTop < 0) {
// Terrain-Oberfläche als Ankerpunkt // Ankerpunkt: Terrain-Oberfläche ODER gebackenes Terrain (brushHitWY),
// je nachdem was höher liegt. So starten neue Voxel sichtbar über
// bestehendem gebackenem Terrain statt darunter.
float th = terrainH(wx, wz); float th = terrainH(wx, wz);
int tCy = VoxelChunk.worldYToCy(th); float anchor = Math.max(th, brushHitWY);
if (cy == tCy) { int aCy = VoxelChunk.worldYToCy(anchor);
if (cy == aCy) {
currentTop = Math.max(0, Math.min(VoxelChunk.SIZE - 1, currentTop = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int)(th - cy * (float) VoxelChunk.CELLS))); (int)(anchor - cy * (float) VoxelChunk.CELLS)));
} else { } else {
// Chunks ober- oder unterhalb des Terrain-Chunks ohne bestehende Voxel: überspringen. // Chunks außerhalb des Anker-Chunks ohne bestehende Voxel: überspringen.
// Kein Foundation-Fill das Terrain-Mesh übernimmt die visuelle Abdeckung.
continue; continue;
} }
} }
@@ -986,85 +1018,61 @@ public class VoxelEditorState extends BaseAppState {
// ── Baked-Chunk Markierung ──────────────────────────────────────────────── // ── Baked-Chunk Markierung ────────────────────────────────────────────────
/** /**
* Markiert (mark=true) oder hebt die Markierung (mark=false) aller gebackenen * Flood-fill BFS: Markiert alle zusammenhängend gebackenen Chunks,
* Chunks auf, deren XZ-Ausdehnung den Pinselkreis schneidet. * ausgehend vom angeklickten Chunk. Bisherige Selektion wird vorher geleert.
* Fügt/entfernt gleichzeitig das rote Overlay-Quad im JME-Szenen-Graph. * LMB = markieren; RMB = Selektion leeren (wird in applyEdit gehandhabt).
*/ */
private void toggleBakedChunkMark(float brushWX, float brushWZ, float radius, boolean mark) { private void markConnectedBakedStructure(float hitX, float hitY, float hitZ) {
float r2 = radius * radius; input.markedDeleteBakedKeys.clear();
int cxMin = VoxelChunk.worldXToCx(brushWX - radius); int startCx = VoxelChunk.worldXToCx(hitX);
int cxMax = VoxelChunk.worldXToCx(brushWX + radius); int startCz = VoxelChunk.worldZToCz(hitZ);
int czMin = VoxelChunk.worldZToCz(brushWZ - radius); int startCy = VoxelChunk.worldYToCy(hitY);
int czMax = VoxelChunk.worldZToCz(brushWZ + radius);
for (int cx = cxMin; cx <= cxMax; cx++) { // Nächsten gebackenen Chunk in der Nähe des Treffers finden
for (int cz = czMin; cz <= czMax; cz++) { boolean found = false;
// Nächster Punkt des Chunk-AABB zum Brush-Mittelpunkt outer:
float chunkX0 = cx * VoxelChunk.CELLS - 2048f; for (int delta = 0; delta <= 4; delta++) {
float chunkZ0 = cz * VoxelChunk.CELLS - 2048f; for (int dcy : (delta == 0 ? new int[]{0} : new int[]{delta, -delta})) {
float nearX = Math.max(chunkX0, Math.min(brushWX, chunkX0 + VoxelChunk.CELLS)); if (VoxelChunkIO.bakedExists(startCx, startCy + dcy, startCz)) {
float nearZ = Math.max(chunkZ0, Math.min(brushWZ, chunkZ0 + VoxelChunk.CELLS)); startCy = startCy + dcy;
float dx = brushWX - nearX, dz = brushWZ - nearZ; found = true;
if (dx*dx + dz*dz > r2) continue; break outer;
}
}
}
if (!found) return;
// Alle cy-Ebenen prüfen, die ein gebackenes LOD0 haben // 6-Richtungs-BFS durch angrenzende gebackene Chunks
for (int cy = -2; cy <= 10; cy++) { Set<Long> visited = new HashSet<>();
if (!VoxelChunkIO.bakedExists(cx, cy, cz)) continue; Queue<long[]> queue = new ArrayDeque<>();
long key = chunkKey(cx, cy, cz); long startKey = chunkKey(startCx, startCy, startCz);
if (mark) { visited.add(startKey);
if (input.markedDeleteBakedKeys.add(key)) { queue.add(new long[]{startCx, startCy, startCz});
addMarkedOverlay(key, cx, cz);
} int[][] dirs = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}};
} else { while (!queue.isEmpty()) {
if (input.markedDeleteBakedKeys.remove(key)) { long[] cur = queue.poll();
removeMarkedOverlay(key); int cx = (int)cur[0], cy = (int)cur[1], cz = (int)cur[2];
} for (int[] d : dirs) {
} int ncx = cx + d[0], ncy = cy + d[1], ncz = cz + d[2];
} long nk = chunkKey(ncx, ncy, ncz);
if (!visited.contains(nk) && VoxelChunkIO.bakedExists(ncx, ncy, ncz)) {
visited.add(nk);
queue.add(new long[]{ncx, ncy, ncz});
} }
} }
} }
private void addMarkedOverlay(long key, int cx, int cz) { // Alle gefundenen Chunks selektieren + Wireframe signalisieren
if (markedOverlays.containsKey(key)) return; for (long key : visited) {
float x0 = cx * VoxelChunk.CELLS - 2048f; input.markedDeleteBakedKeys.add(key);
float z0 = cz * VoxelChunk.CELLS - 2048f;
com.jme3.scene.shape.Quad q = new com.jme3.scene.shape.Quad(VoxelChunk.CELLS, VoxelChunk.CELLS);
Geometry geo = new Geometry("markedBaked_" + key, q);
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(1f, 0.15f, 0.15f, 0.45f));
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setDepthTest(false);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
geo.setMaterial(mat);
geo.setQueueBucket(RenderQueue.Bucket.Transparent);
// Quad liegt im XY-Raum; -90° um X rotieren → horizontale XZ-Ebene
Quaternion rot = new Quaternion();
rot.fromAngleAxis(-FastMath.HALF_PI, Vector3f.UNIT_X);
geo.setLocalRotation(rot);
// Nach Rotation reicht der Quad von (x0, -9, z0) bis (x0+CELLS, -9, z0+CELLS)
geo.setLocalTranslation(x0, -9f, z0 + VoxelChunk.CELLS);
markedChunkOverlayRoot.attachChild(geo);
markedOverlays.put(key, geo);
} }
input.markedBakedSelectionDirty = true;
private void removeMarkedOverlay(long key) {
Geometry geo = markedOverlays.remove(key);
if (geo != null) geo.removeFromParent();
}
private void clearMarkedOverlays() {
for (Geometry geo : markedOverlays.values()) geo.removeFromParent();
markedOverlays.clear();
} }
/** /**
* Löscht die per Brush markierten gebackenen Chunks (alle LODs + Sculpt-Overlays). * Löscht die selektierten gebackenen Chunks (alle LODs + Sculpt-Overlays).
* Läuft im Hintergrund-Thread. * Läuft im Hintergrund-Thread.
*/ */
private void deleteMarkedBaked(Set<Long> keys) { private void deleteMarkedBaked(Set<Long> keys) {
@@ -1477,6 +1485,63 @@ public class VoxelEditorState extends BaseAppState {
input.blurIterDone = iter + 1; input.blurIterDone = iter + 1;
} }
// ── Flachbereich-Glättung: 3 reine Gauß-Passes nur nahe der Isofläche ──────
// Der bilaterale Filter bewahrt scharfe Kanten (Klippen), glättet jedoch die
// Dichte-Sprünge an der Isofläche (127 → -128) kaum, da die Differenz (255) das
// bilaterale Gewicht auf ~0 senkt. Auf flachen Flächen entstehen dadurch Stufen.
// Diese reinen Gauß-Passes (keine bilaterale Gewichtung) glätten gezielt Voxel,
// die (a) nahe der Oberfläche liegen und (b) eine überwiegend vertikale Normale haben.
{
final float SURF_BAND = 80f; // |dichte| < SURF_BAND → nahe der Isofläche
final float COS_30 = 0.866f; // cos(30°) Grenze "flache Fläche"
for (int flatPass = 0; flatPass < 3; flatPass++) {
Map<Long, float[]> nextBufs = new HashMap<>();
for (VoxelChunk c : nonEmpty) {
long k = chunkKey(c.cx, c.cy, c.cz);
float[] cur = curBufs.get(k);
float[] next = Arrays.copyOf(cur, cur.length);
for (int by = 0; by < blurN; by++) {
for (int bz = 0; bz < blurN; bz++) {
for (int bx = 0; bx < blurN; bx++) {
float d = cur[c.idx(bx, by, bz)];
if (Math.abs(d) >= SURF_BAND) continue;
// Gradient über zentrale Differenzen
float gx = getBlurBuf(curBufs, allOriginal, c, bx+1, by, bz)
- getBlurBuf(curBufs, allOriginal, c, bx-1, by, bz);
float gy = getBlurBuf(curBufs, allOriginal, c, bx, by+1, bz)
- getBlurBuf(curBufs, allOriginal, c, bx, by-1, bz);
float gz = getBlurBuf(curBufs, allOriginal, c, bx, by, bz+1)
- getBlurBuf(curBufs, allOriginal, c, bx, by, bz-1);
float gLen = (float) Math.sqrt(gx*gx + gy*gy + gz*gz);
if (gLen < 1f) continue;
float normY = Math.abs(gy) / gLen;
if (normY < COS_30) continue; // steile Fläche → nicht glätten
float vSum = 0f;
int cnt = 0;
for (int dy = -1; dy <= 1; dy++)
for (int dz = -1; dz <= 1; dz++)
for (int dx = -1; dx <= 1; dx++) {
int sx = bx+dx, sy = by+dy, sz = bz+dz;
float nb;
if (sx >= 0 && sx < blurN && sy >= 0 && sy < blurN && sz >= 0 && sz < blurN)
nb = cur[c.idx(sx, sy, sz)];
else
nb = getBlurBuf(curBufs, allOriginal, c, sx, sy, sz);
vSum += nb;
cnt++;
}
next[c.idx(bx, by, bz)] = vSum / cnt;
}
}
}
nextBufs.put(k, next);
}
curBufs = nextBufs;
}
}
// Blur-Ergebnisse in VoxelChunks umwandeln // Blur-Ergebnisse in VoxelChunks umwandeln
Map<Long, VoxelChunk> blurredMap = new HashMap<>(); Map<Long, VoxelChunk> blurredMap = new HashMap<>();
for (VoxelChunk c : nonEmpty) { for (VoxelChunk c : nonEmpty) {
@@ -1587,21 +1652,97 @@ public class VoxelEditorState extends BaseAppState {
} }
} }
// ── Steile-Flächen-Noise: variierte Dreieckgröße für Hänge > 30° ─────────
// Für Voxel nahe der Iso-Fläche (|d| < SURF_BAND) mit steiler Oberflächen-
// Normale (> 30°) wird deterministisches fBm-Rauschen auf die Dichte addiert.
// Der MC interpoliert Vertices entlang der Kanten → unterschiedliche t-Parameter
// → organisch verschiedene Dreieckgrößen. Amplitude ist auf |density| * 0.72
// begrenzt, damit kein Vorzeichenwechsel an Voxelzentren entsteht (keine Löcher).
// Noise-Koordinaten sind weltrelativ → nahtlose Chunk-Grenzen ohne Sichtbare Naht.
{
final float COS30 = 0.866f; // cos(30°) Grenze "steil"
final float SURF_BAND = 35f; // |density|-Schwelle für "nahe der Oberfläche"
final float AMP_MAX = 24f; // maximale Rauschstärke (Dichte-Einheiten)
final float NOISE_FREQ = 0.32f; // Rauschfrequenz in Welt-Voxel-Einheiten (~3-Voxel-Wellenlänge)
final int NOISE_SEED = 9137;
for (VoxelChunk blurred : blurredMap.values()) {
float wx0 = blurred.cx * (float) VoxelChunk.CELLS;
float wy0 = blurred.cy * (float) VoxelChunk.CELLS;
float wz0 = blurred.cz * (float) VoxelChunk.CELLS;
for (int ly = 0; ly < blurN; ly++) {
for (int lz = 0; lz < blurN; lz++) {
for (int lx = 0; lx < blurN; lx++) {
float d = blurred.getDensity(lx, ly, lz);
if (Math.abs(d) >= SURF_BAND) continue;
// Gradient via zentrale Differenzen (clamped an Chunk-Grenzen)
float gx = sampleBlurred(blurred, lx+1, ly, lz )
- sampleBlurred(blurred, lx-1, ly, lz );
float gy = sampleBlurred(blurred, lx, ly+1, lz )
- sampleBlurred(blurred, lx, ly-1, lz );
float gz = sampleBlurred(blurred, lx, ly, lz+1)
- sampleBlurred(blurred, lx, ly, lz-1);
float gLen = (float) Math.sqrt(gx*gx + gy*gy + gz*gz);
if (gLen < 2f) continue; // kein klares Gefälle → überspringen
float normY = Math.abs(gy) / gLen;
if (normY >= COS30) continue; // weniger als 30° Neigung
// Steile-Flächen-Faktor: 0 bei exakt 30°, 1 bei 90°
float steepness = 1f - normY / COS30;
// Amplitude so begrenzen, dass das Vorzeichen erhalten bleibt
float amp = Math.min(Math.abs(d) * 0.72f, AMP_MAX * steepness);
if (amp < 0.5f) continue;
float noise = noise3DfBm(
(wx0 + lx) * NOISE_FREQ,
(wy0 + ly) * NOISE_FREQ,
(wz0 + lz) * NOISE_FREQ,
3, 0.55f, NOISE_SEED);
float newD = Math.max(-127f, Math.min(127f, d + noise * amp));
blurred.setDensity(lx, ly, lz, (byte) Math.round(newD));
}
}
}
}
}
// Pre-Bake-Backup anlegen (ermöglicht Revert nach dem Bake).
// Wichtig: zuerst .blvc auf Disk speichern, falls Auto-Save noch nicht gelaufen ist,
// damit savePreBake() etwas zum Kopieren hat.
for (VoxelChunk chunk : nonEmpty) {
try {
VoxelChunkIO.save(chunk);
VoxelChunkIO.savePreBake(chunk.cx, chunk.cy, chunk.cz);
} catch (Exception e) { log.warn("Pre-Bake-Backup fehlgeschlagen ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage()); }
}
// Nur erfolgreich gebackene Chunks werden gelöscht
List<VoxelChunk> successfullyBaked = new java.util.ArrayList<>();
int baked = 0; int baked = 0;
for (VoxelChunk chunk : nonEmpty) { for (VoxelChunk chunk : nonEmpty) {
bakeChunk(chunk, blurredMap); if (bakeChunk(chunk, blurredMap)) {
successfullyBaked.add(chunk);
} else {
log.warn("Chunk ({},{},{}) nicht gebacken Voxel-Daten bleiben erhalten.",
chunk.cx, chunk.cy, chunk.cz);
}
baked++; baked++;
input.bakeDone = baked; input.bakeDone = baked;
} }
// Voxel-Daten löschen: Disk-Dateien entfernen, In-Memory leeren, Szene-Nodes entfernen
for (VoxelChunk chunk : nonEmpty) { // Voxel-Daten löschen: nur für erfolgreich gebackene Chunks
for (VoxelChunk chunk : successfullyBaked) {
chunk.clear(); chunk.clear();
try { VoxelChunkIO.delete(chunk.cx, chunk.cy, chunk.cz); } try { VoxelChunkIO.delete(chunk.cx, chunk.cy, chunk.cz); }
catch (Exception e) { log.warn("Voxel-Datei löschen fehlgeschlagen ({},{},{}): {}", catch (Exception e) { log.warn("Voxel-Datei löschen fehlgeschlagen ({},{},{}): {}",
chunk.cx, chunk.cy, chunk.cz, e.getMessage()); } chunk.cx, chunk.cy, chunk.cz, e.getMessage()); }
} }
app.enqueue(() -> { app.enqueue(() -> {
for (VoxelChunk chunk : nonEmpty) { for (VoxelChunk chunk : successfullyBaked) {
long key = chunkKey(chunk.cx, chunk.cy, chunk.cz); long key = chunkKey(chunk.cx, chunk.cy, chunk.cz);
VoxelChunkNode node = nodes.remove(key); VoxelChunkNode node = nodes.remove(key);
if (node != null) node.removeFromParent(); if (node != null) node.removeFromParent();
@@ -1609,18 +1750,100 @@ public class VoxelEditorState extends BaseAppState {
} }
}); });
String msg = "Fertig: " + baked + " Chunk" + (baked != 1 ? "s" : "") + " gebacken."; int failed = nonEmpty.size() - successfullyBaked.size();
String msg = "Fertig: " + successfullyBaked.size() + " Chunk"
+ (successfullyBaked.size() != 1 ? "s" : "") + " gebacken"
+ (failed > 0 ? ", " + failed + " fehlgeschlagen (Voxel erhalten)" : "") + ".";
// bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt // bakeTotal/bakeDone werden vom UI-Thread nach Empfang der Statusmeldung zurückgesetzt
input.bakeStatusMsg = msg; input.bakeStatusMsg = msg;
input.sculptRescanNeeded = true; input.sculptRescanNeeded = true;
log.info("Voxel-Bake abgeschlossen {}.", msg); log.info("Voxel-Bake abgeschlossen {}.", msg);
} }
/** Bäckt einen einzelnen Chunk mit bereits berechneten geblurrten Daten. */ /**
private void bakeChunk(VoxelChunk original, Map<Long, VoxelChunk> blurredMap) { * Stellt die selektierten Chunks auf den Vor-Bake-Zustand zurück:
* - Wenn ein Pre-Bake-Backup (.blvc.prebake) existiert, wird es eingespielt.
* - In jedem Fall werden die Baked-J3O-Dateien gelöscht, so dass
* die aktuellen .blvc-Voxeldaten wieder als ungebackene Meshes sichtbar sind.
*/
private void revertMarkedBaked(Set<Long> keys) {
if (keys.isEmpty()) {
input.bakeStatusMsg = "Keine Chunks markiert.";
return;
}
int restored = 0, noBackup = 0, failed = 0;
List<VoxelChunk> reloadedChunks = new java.util.ArrayList<>();
for (long key : keys) {
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;
try {
boolean hadBackup = de.blight.common.VoxelChunkIO.preBakeExists(cx, cy, cz);
boolean hasBlvc = de.blight.common.VoxelChunkIO.exists(cx, cy, cz);
if (!hadBackup && !hasBlvc) {
// Kein Backup vorhanden J3O behalten, sonst entsteht leerer Bereich
noBackup++;
continue;
}
if (hadBackup) {
// Backup vorhanden → .blvc aus Backup wiederherstellen
de.blight.common.VoxelChunkIO.restorePreBake(cx, cy, cz);
de.blight.common.VoxelChunkIO.deletePreBake(cx, cy, cz);
}
// Baked J3O-Dateien löschen, damit das ungebackene Mesh sichtbar wird
for (int lod = 0; lod < 3; lod++) {
Files.deleteIfExists(de.blight.common.VoxelChunkIO.getBakedPath(cx, cy, cz, lod));
}
// Chunk-Daten (aus Backup oder aktuellem .blvc) in die Voxel-Engine laden
if (de.blight.common.VoxelChunkIO.exists(cx, cy, cz)) {
VoxelChunk chunk = de.blight.common.VoxelChunkIO.load(cx, cy, cz);
reloadedChunks.add(chunk);
}
restored++;
} catch (Exception e) {
log.error("Markierter-Revert fehlgeschlagen ({},{},{}): {}", cx, cy, cz, e.getMessage());
failed++;
}
}
List<VoxelChunk> toAdd = new java.util.ArrayList<>(reloadedChunks);
app.enqueue(() -> {
for (VoxelChunk chunk : toAdd) {
long k = chunkKey(chunk.cx, chunk.cy, chunk.cz);
VoxelChunkNode oldNode = nodes.remove(k);
if (oldNode != null) oldNode.removeFromParent();
chunks.put(k, chunk);
addNodeForChunk(k, chunk);
}
});
input.sculptRescanNeeded = true;
StringBuilder sb = new StringBuilder();
if (restored > 0) {
sb.append("Bake rückgängig: ").append(restored).append(" Chunk")
.append(restored != 1 ? "s" : "").append(".");
}
if (noBackup > 0) {
if (sb.length() > 0) sb.append(" ");
sb.append(noBackup).append(" ohne Backup unverändert.");
}
if (failed > 0) {
if (sb.length() > 0) sb.append(" ");
sb.append(failed).append(" fehlgeschlagen.");
}
if (sb.length() == 0) sb.append("Nichts wiederhergestellt.");
input.bakeStatusMsg = sb.toString();
log.info("Markierter-Revert: {} erledigt, {} fehlgeschlagen.", restored, failed);
}
/** Bäckt einen einzelnen Chunk. Gibt true zurück wenn erfolgreich, false bei Fehler. */
private boolean bakeChunk(VoxelChunk original, Map<Long, VoxelChunk> blurredMap) {
try { try {
VoxelChunk blurred = blurredMap.get(chunkKey(original.cx, original.cy, original.cz)); VoxelChunk blurred = blurredMap.get(chunkKey(original.cx, original.cy, original.cz));
if (blurred == null) return; if (blurred == null) return false;
// Geblurrte Nachbarn für nahtlose Chunk-Grenzen im MC // Geblurrte Nachbarn für nahtlose Chunk-Grenzen im MC
VoxelChunk[] nb = getNeighbors(original.cx, original.cy, original.cz, blurredMap); VoxelChunk[] nb = getNeighbors(original.cx, original.cy, original.cz, blurredMap);
@@ -1639,9 +1862,11 @@ public class VoxelEditorState extends BaseAppState {
exp.save(meshes[lod], p.toFile()); exp.save(meshes[lod], p.toFile());
} }
log.debug("Chunk ({},{},{}) gebacken.", original.cx, original.cy, original.cz); log.debug("Chunk ({},{},{}) gebacken.", original.cx, original.cy, original.cz);
return true;
} catch (Exception e) { } catch (Exception e) {
log.error("Bake fehlgeschlagen ({},{},{}): {}", log.error("Bake fehlgeschlagen ({},{},{}): {}",
original.cx, original.cy, original.cz, e.getMessage()); original.cx, original.cy, original.cz, e.getMessage());
return false;
} }
} }
@@ -1670,6 +1895,15 @@ public class VoxelEditorState extends BaseAppState {
return nb != null ? nb.getDensity(lx, ly, lz) : Byte.MIN_VALUE; return nb != null ? nb.getDensity(lx, ly, lz) : Byte.MIN_VALUE;
} }
/** Liest Dichte aus einem geblurrten Chunk, clamped an Chunk-Grenzen. Nur für Gradienten-Berechnung. */
private static float sampleBlurred(VoxelChunk chunk, int x, int y, int z) {
int n = VoxelChunk.SIZE;
return chunk.getDensity(
Math.max(0, Math.min(n - 1, x)),
Math.max(0, Math.min(n - 1, y)),
Math.max(0, Math.min(n - 1, z)));
}
private float computeFalloff(int mode, float t) { private float computeFalloff(int mode, float t) {
return switch (mode) { return switch (mode) {
case de.blight.editor.tool.VoxelTool.MODE_SINUS -> (float) Math.cos(t * Math.PI / 2); case de.blight.editor.tool.VoxelTool.MODE_SINUS -> (float) Math.cos(t * Math.PI / 2);
@@ -2179,6 +2413,12 @@ public class VoxelEditorState extends BaseAppState {
brushIndicator.setCullHint(Spatial.CullHint.Always); brushIndicator.setCullHint(Spatial.CullHint.Always);
return; return;
} }
// Im Selektierungs-Modus nur Mauszeiger, kein Brush-Indikator
if (input.activeLayer == SharedInput.LAYER_VOXEL
&& input.voxelTool.mode.getSelectedIndex() == de.blight.editor.tool.VoxelTool.MODE_DELETE_BAKED) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
float mx = input.mouseScreenX; float mx = input.mouseScreenX;
float my = input.mouseScreenY; float my = input.mouseScreenY;
if (mx < 0) { if (mx < 0) {
@@ -2351,8 +2591,6 @@ public class VoxelEditorState extends BaseAppState {
basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
if (chunkGrid != null) if (chunkGrid != null)
chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); chunkGrid.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
if (markedChunkOverlayRoot != null)
markedChunkOverlayRoot.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always);
// Wireframe-Zustand beim Layer-Wechsel NICHT ändern — wird global via Ctrl+G gesteuert. // Wireframe-Zustand beim Layer-Wechsel NICHT ändern — wird global via Ctrl+G gesteuert.
} }

View File

@@ -31,7 +31,7 @@ public class VoxelTool extends EditorTool {
public final ChoiceToolParameter mode = new ChoiceToolParameter( public final ChoiceToolParameter mode = new ChoiceToolParameter(
"Modus", "Modus",
new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen", "Baked löschen"}, new String[]{"Sinus", "Spike", "Plateau", "Smooth", "Aushöhlen", "Zurücksetzen", "Baked selektieren"},
MODE_SINUS, MODE_SINUS,
new String[]{ new String[]{
"img/editor/terraintool_sinus.png", "img/editor/terraintool_sinus.png",