diff --git a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java index 02fadcf..2186f8e 100644 --- a/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java +++ b/blight-common/src/main/java/de/blight/common/VoxelChunkIO.java @@ -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 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 listPreBakeCoords() { + List result = new java.util.ArrayList<>(); + Path dir = ChunkTerrainIO.chunksDir(); + if (!Files.isDirectory(dir)) return result; + try (DirectoryStream 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. * Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert. diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 8106565..7a60ec5 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -827,6 +827,11 @@ public class EditorApp extends Application { vt.plateauTargetChanged = false; 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 javafx.scene.image.WritableImage newImg = input.resizedImage.getAndSet(null); @@ -3542,13 +3547,12 @@ public class EditorApp extends Application { 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 -> { int total = input.bakeTotal; int done = input.bakeDone; if (total > 0) { - double prog = (double) done / total; - bakeBar.setProgress(prog); + bakeBar.setProgress((double) done / total); bakeBarLabel.setText(done + " / " + total + " Chunks"); } String msg = input.bakeStatusMsg; @@ -3565,41 +3569,60 @@ public class EditorApp extends Application { } }) ); - poller.setCycleCount(javafx.animation.Animation.INDEFINITE); - poller.play(); + bakePoller.setCycleCount(javafx.animation.Animation.INDEFINITE); + bakePoller.play(); - // ── Markierte Baked-Chunks löschen (Modus 6) ───────────────────── - Label markCountLabel = new Label("0 Chunks markiert"); - markCountLabel.setStyle("-fx-font-size: 11; -fx-text-fill: #555;"); + // ── Selektion: Löschen / Bake rückgängig (Modus "Baked selektieren") ── + Label selInfoLabel = new Label("Keine Auswahl"); + 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"); - deleteMarkedBtn.setMaxWidth(Double.MAX_VALUE); - deleteMarkedBtn.setStyle("-fx-background-color: #ba4a4a; -fx-text-fill: white;"); - deleteMarkedBtn.setDisable(true); - deleteMarkedBtn.setOnAction(e -> { - input.deleteMarkedBakedRequested = true; - deleteMarkedBtn.setDisable(true); + Button deleteBtn = new Button("Löschen"); + deleteBtn.setMaxWidth(Double.MAX_VALUE); + deleteBtn.setStyle("-fx-background-color: #ba4a4a; -fx-text-fill: white;"); + deleteBtn.setDisable(true); + 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; + } + }); }); - Button clearMarkBtn = new Button("Markierung leeren"); - clearMarkBtn.setMaxWidth(Double.MAX_VALUE); - clearMarkBtn.setDisable(true); - clearMarkBtn.setOnAction(e -> input.clearMarkedBakedRequested = true); + Button revertBtn = new Button("Bake rückgängig"); + revertBtn.setMaxWidth(Double.MAX_VALUE); + revertBtn.setStyle("-fx-background-color: #7a5a2a; -fx-text-fill: white;"); + revertBtn.setDisable(true); + revertBtn.setOnAction(e -> input.revertMarkedBakedRequested = true); - // Poller: Anzahl markierter Chunks + Bake-Status aktualisieren - javafx.animation.Timeline markPoller = new javafx.animation.Timeline( + javafx.animation.Timeline selPoller = new javafx.animation.Timeline( new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> { int n = input.markedDeleteBakedKeys.size(); - markCountLabel.setText(n + " Chunk" + (n != 1 ? "s" : "") + " markiert"); - deleteMarkedBtn.setDisable(n == 0); - clearMarkBtn.setDisable(n == 0); + boolean has = n > 0; + selInfoLabel.setText(has + ? n + " Chunk" + (n != 1 ? "s" : "") + " ausgewählt" + : "Keine Auswahl"); + deleteBtn.setDisable(!has); + revertBtn.setDisable(!has); }) ); - markPoller.setCycleCount(javafx.animation.Animation.INDEFINITE); - markPoller.play(); + selPoller.setCycleCount(javafx.animation.Animation.INDEFINITE); + selPoller.play(); - panel.getChildren().addAll(new Separator(), bakeBtn, bakeBar, bakeBarLabel, bakeStatus, - new Separator(), markCountLabel, deleteMarkedBtn, clearMarkBtn); + panel.getChildren().addAll(new Separator(), bakeBtn, + 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) { input.objectClickQueue.offer( new SharedInput.ObjectClick((float)e.getX(), (float)e.getY(), true, false)); + objDragPrevX = e.getX(); objDragPrevY = e.getY(); } return; } @@ -7731,6 +7755,12 @@ public class EditorApp extends Application { float dy = (float)(e.getY() - objDragPrevY); input.objectDragQueue.offer(new SharedInput.ObjectDrag(dx, dy)); 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; } diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index 7866e80..9372aee 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -262,6 +262,10 @@ public class SharedInput { public record ObjectDrag(float dx, float dy) {} public final ConcurrentLinkedQueue 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 objectRotateDragQueue = new ConcurrentLinkedQueue<>(); + /** 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) // "N" (N≥2 Objekte ausgewählt) @@ -782,15 +786,17 @@ public class SharedInput { 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). */ public final java.util.Set markedDeleteBakedKeys = 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; - /** JFX → JME: Markierung ohne Löschen leeren. */ - public volatile boolean clearMarkedBakedRequested = false; + /** JFX → JME: selektierte Chunks aus Pre-Bake-Backup wiederherstellen. */ + public volatile boolean revertMarkedBakedRequested = false; /** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */ public volatile int voxelFlatSlot = -1; diff --git a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java index 239e6de..3cad917 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/GrassVertexState.java @@ -216,22 +216,43 @@ public class GrassVertexState extends BaseAppState { if (terrain == null) continue; float jmeX = (float) (edit.screenX() * input.viewportScaleX); 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 (edit.action() > 0) addBlades(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; Vector3f origin = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 0f); Vector3f direction = cam.getWorldCoordinates(new Vector2f(screenX, flippedY), 1f) .subtractLocal(origin).normalizeLocal(); Ray ray = new Ray(origin, direction); + CollisionResults res = new CollisionResults(); 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(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java index 74f755f..c22150f 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/PlacedObjectState.java @@ -253,16 +253,40 @@ public class PlacedObjectState extends BaseAppState { 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) continue; - Vector3f contact = hits.getClosestCollision().getContactPoint(); + Vector3f contact = raycastSurface(ray); + if (contact == null) continue; float radius = (float) input.grassTool.brushRadius.getValue(); if (edit.action() > 0) paintGrass(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) { int n = Math.max(1, (int) input.grassTool.density.getValue()); float baseH = (float) input.grassTool.grassHeight.getValue(); diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index 97a4e26..4bacc66 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -107,6 +107,9 @@ public class SceneObjectState extends BaseAppState { private float currentRandomRotY = 0f; 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 Geometry subSelGeom = null; // Selektierte Geometry (alle Sub-Modi) @@ -355,6 +358,12 @@ public class SceneObjectState extends BaseAppState { 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(); if (input.reloadPlacedModels) { @@ -500,13 +509,10 @@ public class SceneObjectState extends BaseAppState { if (snapped != null) pt = snapped; } previewNode.setLocalTranslation(pt.x, pt.y, pt.z); - if (input.treeFolderPath != null) { - com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); - rot.fromAngleAxis(currentRandomRotY, com.jme3.math.Vector3f.UNIT_Y); - previewNode.setLocalRotation(rot); - } else { - previewNode.setLocalRotation(com.jme3.math.Quaternion.IDENTITY); - } + float totalRotY = (input.treeFolderPath != null ? currentRandomRotY : 0f) + previewRotY; + com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); + rot.fromAngleAxis(totalRotY, com.jme3.math.Vector3f.UNIT_Y); + previewNode.setLocalRotation(rot); previewNode.setCullHint(Spatial.CullHint.Inherit); } @@ -567,10 +573,11 @@ public class SceneObjectState extends BaseAppState { // ── Klick-Handling ──────────────────────────────────────────────────────── 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 (input.treeFolderPath != null && !folderTreeAssetPaths.isEmpty()) { applyRandomTree(); + previewRotY = 0f; } return; } @@ -635,10 +642,12 @@ public class SceneObjectState extends BaseAppState { if (snapped != null) pt = snapped; } 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); - // After placing in folder mode, re-randomize for the next placement - if (input.treeFolderPath != null) applyRandomTree(); + if (input.treeFolderPath != null) { + applyRandomTree(); + previewRotY = 0f; + } } // ── Objekt platzieren ──────────────────────────────────────────────────── diff --git a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java index 8cd5d13..16eb567 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SculptedMeshEditorState.java @@ -3,6 +3,7 @@ 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.CollisionResult; import com.jme3.collision.CollisionResults; import com.jme3.export.binary.BinaryImporter; import com.jme3.material.Material; @@ -68,6 +69,8 @@ public class SculptedMeshEditorState extends BaseAppState { private long selectedKey = -1L; private Material normalMat = null; private Material highlightMat = null; + private Material selectionWireframeMat = null; + private final Set appliedSelectionKeys = new HashSet<>(); // ── innere Klasse ───────────────────────────────────────────────────────── @@ -120,6 +123,10 @@ public class SculptedMeshEditorState extends BaseAppState { highlightMat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md"); 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(); app.getRootNode().attachChild(brushIndicator); @@ -129,11 +136,45 @@ public class SculptedMeshEditorState extends BaseAppState { @Override protected void cleanup(Application application) { 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(); if (brushIndicator != null) brushIndicator.removeFromParent(); meshes.clear(); } + private void updateSelectionWireframe() { + Set current = new HashSet<>(input.markedDeleteBakedKeys); + // Wireframe von nicht mehr selektierten Chunks entfernen + Iterator 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 onDisable() {} @@ -152,6 +193,12 @@ public class SculptedMeshEditorState extends BaseAppState { if (input.sculptRescanNeeded) { input.sculptRescanNeeded = false; rescanBakedChunks(); + input.markedBakedSelectionDirty = true; + } + + if (input.markedBakedSelectionDirty) { + input.markedBakedSelectionDirty = false; + updateSelectionWireframe(); } updateBrushIndicator(); @@ -756,4 +803,24 @@ public class SculptedMeshEditorState extends BaseAppState { private static long chunkKey(int cx, int cy, int cz) { 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}; + } } diff --git a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java index 0a3c146..cda4d88 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/TerrainEditorState.java @@ -1117,10 +1117,10 @@ public class TerrainEditorState extends BaseAppState { Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f); com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal()); - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) continue; - Vector3f contact = hits.getClosestCollision().getContactPoint(); + CollisionResults texHits = new CollisionResults(); + terrain.collideWith(ray, texHits); + if (texHits.size() == 0) continue; + Vector3f contact = texHits.getClosestCollision().getContactPoint(); int selIdx = input.textureTool.textureIndex.getSelectedIndex(); 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). */ 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. */ public float getTerrainHeightAt(float worldX, float worldZ) { if (terrain == null) return 0f; @@ -1347,27 +1377,21 @@ public class TerrainEditorState extends BaseAppState { float brushRadius = 0f; if (layer == 0 || layer == 4) { - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() > 0) { - contactPoint = hits.getClosestCollision().getContactPoint(); - brushRadius = (layer == 0) + contactPoint = raycastSurface(ray); + if (contactPoint != null) { + brushRadius = (layer == 0) ? (float) input.heightTool.brushRadius.getValue() : (float) input.textureTool.brushRadius.getValue(); } } else if (layer == 3) { - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() > 0) { - contactPoint = hits.getClosestCollision().getContactPoint(); - brushRadius = (float) input.grassTool.brushRadius.getValue(); + contactPoint = raycastSurface(ray); + if (contactPoint != null) { + brushRadius = (float) input.grassTool.brushRadius.getValue(); } } else if (layer == SharedInput.LAYER_GRASS_VERTEX) { - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() > 0) { - contactPoint = hits.getClosestCollision().getContactPoint(); - brushRadius = (float) input.grassVertexTool.brushRadius.getValue(); + contactPoint = raycastSurface(ray); + if (contactPoint != null) { + brushRadius = (float) input.grassVertexTool.brushRadius.getValue(); } } @@ -1410,20 +1434,16 @@ public class TerrainEditorState extends BaseAppState { 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(); terrain.collideWith(ray, hits); if (hits.size() == 0) continue; - 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 (edit.action() > 0) { slopeHeight(contact); diff --git a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java index 2cbc969..9e79f05 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/VoxelEditorState.java @@ -131,13 +131,6 @@ public class VoxelEditorState extends BaseAppState { 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 markedOverlays = new HashMap<>(); - // ── Basis-Terrain-Referenzebene (y = -10) ──────────────────────────────── /** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */ @@ -216,11 +209,6 @@ public class VoxelEditorState extends BaseAppState { brushIndicator = buildBrushIndicator(); 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 basePlaneNode = new Node("voxelBasePlaneNode"); basePlane = buildBasePlane(); @@ -245,7 +233,6 @@ public class VoxelEditorState extends BaseAppState { voxelRoot.removeFromParent(); if (brushIndicator != null) brushIndicator.removeFromParent(); if (basePlaneNode != null) basePlaneNode.removeFromParent(); - if (markedChunkOverlayRoot != null) markedChunkOverlayRoot.removeFromParent(); if (wireframeActive) applyWireframe(false); nodes.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) { input.deleteMarkedBakedRequested = false; Set toDelete = new HashSet<>(input.markedDeleteBakedKeys); input.markedDeleteBakedKeys.clear(); - clearMarkedOverlays(); + input.markedBakedSelectionDirty = true; executor.submit(() -> { try { deleteMarkedBaked(toDelete); @@ -306,11 +293,20 @@ public class VoxelEditorState extends BaseAppState { }); } - // Markierung leeren - if (input.clearMarkedBakedRequested) { - input.clearMarkedBakedRequested = false; + // Selektierte Chunks aus Pre-Bake-Backup wiederherstellen + if (input.revertMarkedBakedRequested) { + input.revertMarkedBakedRequested = false; + Set toRevert = new HashSet<>(input.markedDeleteBakedKeys); 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? @@ -630,6 +626,22 @@ public class VoxelEditorState extends BaseAppState { 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 if (basePlaneNode != null && basePlane != null && basePlane.getCullHint() != Spatial.CullHint.Always) { @@ -674,9 +686,17 @@ public class VoxelEditorState extends BaseAppState { int modeIdx = input.voxelTool.mode.getSelectedIndex(); 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) { - 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; } @@ -728,6 +748,16 @@ public class VoxelEditorState extends BaseAppState { int cyMin = 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) float[] slopeParams = null; 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); } } 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 @@ -873,6 +903,7 @@ public class VoxelEditorState extends BaseAppState { */ private void applyColumnBrush(VoxelChunk chunk, int cx, int cy, int cz, float brushWX, float brushWZ, + float brushHitWY, float radius, float strength, int mode, boolean lower) { float lxC = VoxelChunk.worldXToLocal(brushWX, cx); @@ -898,9 +929,8 @@ public class VoxelEditorState extends BaseAppState { float t = (float) Math.sqrt(d2) / radius; float falloff = computeFalloff(mode, t); - // Smooth/Sinus: Kanten bekommen weniger Schritt → richtiges Profil - int colStep = (int)(stepBase * falloff); - if (colStep < 1) continue; + // Mindestens 1, damit Kanten-Spalten nicht komplett übersprungen werden + int colStep = Math.max(1, (int)(stepBase * falloff)); if (!lower) { // 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 (currentTop < 0) { - // Terrain-Oberfläche als Ankerpunkt - float th = terrainH(wx, wz); - int tCy = VoxelChunk.worldYToCy(th); - if (cy == tCy) { + // 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 anchor = Math.max(th, brushHitWY); + int aCy = VoxelChunk.worldYToCy(anchor); + if (cy == aCy) { currentTop = Math.max(0, Math.min(VoxelChunk.SIZE - 1, - (int)(th - cy * (float) VoxelChunk.CELLS))); + (int)(anchor - cy * (float) VoxelChunk.CELLS))); } else { - // Chunks ober- oder unterhalb des Terrain-Chunks ohne bestehende Voxel: überspringen. - // Kein Foundation-Fill – das Terrain-Mesh übernimmt die visuelle Abdeckung. + // Chunks außerhalb des Anker-Chunks ohne bestehende Voxel: überspringen. continue; } } @@ -986,85 +1018,61 @@ public class VoxelEditorState extends BaseAppState { // ── Baked-Chunk Markierung ──────────────────────────────────────────────── /** - * Markiert (mark=true) oder hebt die Markierung (mark=false) aller gebackenen - * Chunks auf, deren XZ-Ausdehnung den Pinselkreis schneidet. - * Fügt/entfernt gleichzeitig das rote Overlay-Quad im JME-Szenen-Graph. + * Flood-fill BFS: Markiert alle zusammenhängend gebackenen Chunks, + * ausgehend vom angeklickten Chunk. Bisherige Selektion wird vorher geleert. + * LMB = markieren; RMB = Selektion leeren (wird in applyEdit gehandhabt). */ - private void toggleBakedChunkMark(float brushWX, float brushWZ, float radius, boolean mark) { - float r2 = radius * radius; + private void markConnectedBakedStructure(float hitX, float hitY, float hitZ) { + input.markedDeleteBakedKeys.clear(); - int cxMin = VoxelChunk.worldXToCx(brushWX - radius); - int cxMax = VoxelChunk.worldXToCx(brushWX + radius); - int czMin = VoxelChunk.worldZToCz(brushWZ - radius); - int czMax = VoxelChunk.worldZToCz(brushWZ + radius); + int startCx = VoxelChunk.worldXToCx(hitX); + int startCz = VoxelChunk.worldZToCz(hitZ); + int startCy = VoxelChunk.worldYToCy(hitY); - for (int cx = cxMin; cx <= cxMax; cx++) { - for (int cz = czMin; cz <= czMax; cz++) { - // Nächster Punkt des Chunk-AABB zum Brush-Mittelpunkt - float chunkX0 = cx * VoxelChunk.CELLS - 2048f; - float chunkZ0 = cz * VoxelChunk.CELLS - 2048f; - float nearX = Math.max(chunkX0, Math.min(brushWX, chunkX0 + VoxelChunk.CELLS)); - float nearZ = Math.max(chunkZ0, Math.min(brushWZ, chunkZ0 + VoxelChunk.CELLS)); - float dx = brushWX - nearX, dz = brushWZ - nearZ; - if (dx*dx + dz*dz > r2) continue; - - // Alle cy-Ebenen prüfen, die ein gebackenes LOD0 haben - for (int cy = -2; cy <= 10; cy++) { - if (!VoxelChunkIO.bakedExists(cx, cy, cz)) continue; - long key = chunkKey(cx, cy, cz); - if (mark) { - if (input.markedDeleteBakedKeys.add(key)) { - addMarkedOverlay(key, cx, cz); - } - } else { - if (input.markedDeleteBakedKeys.remove(key)) { - removeMarkedOverlay(key); - } - } + // Nächsten gebackenen Chunk in der Nähe des Treffers finden + boolean found = false; + outer: + for (int delta = 0; delta <= 4; delta++) { + for (int dcy : (delta == 0 ? new int[]{0} : new int[]{delta, -delta})) { + if (VoxelChunkIO.bakedExists(startCx, startCy + dcy, startCz)) { + startCy = startCy + dcy; + found = true; + break outer; } } } - } + if (!found) return; - private void addMarkedOverlay(long key, int cx, int cz) { - if (markedOverlays.containsKey(key)) return; - float x0 = cx * VoxelChunk.CELLS - 2048f; - float z0 = cz * VoxelChunk.CELLS - 2048f; + // 6-Richtungs-BFS durch angrenzende gebackene Chunks + Set visited = new HashSet<>(); + Queue queue = new ArrayDeque<>(); + long startKey = chunkKey(startCx, startCy, startCz); + visited.add(startKey); + queue.add(new long[]{startCx, startCy, startCz}); - com.jme3.scene.shape.Quad q = new com.jme3.scene.shape.Quad(VoxelChunk.CELLS, VoxelChunk.CELLS); - Geometry geo = new Geometry("markedBaked_" + key, q); + int[][] dirs = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}}; + while (!queue.isEmpty()) { + long[] cur = queue.poll(); + 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}); + } + } + } - 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); - } - - 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(); + // Alle gefundenen Chunks selektieren + Wireframe signalisieren + for (long key : visited) { + input.markedDeleteBakedKeys.add(key); + } + input.markedBakedSelectionDirty = true; } /** - * 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. */ private void deleteMarkedBaked(Set keys) { @@ -1477,6 +1485,63 @@ public class VoxelEditorState extends BaseAppState { 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 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 Map blurredMap = new HashMap<>(); 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 successfullyBaked = new java.util.ArrayList<>(); int baked = 0; 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++; 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(); 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) { + for (VoxelChunk chunk : successfullyBaked) { long key = chunkKey(chunk.cx, chunk.cy, chunk.cz); VoxelChunkNode node = nodes.remove(key); 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 input.bakeStatusMsg = msg; input.sculptRescanNeeded = true; log.info("Voxel-Bake abgeschlossen – {}.", msg); } - /** Bäckt einen einzelnen Chunk mit bereits berechneten geblurrten Daten. */ - private void bakeChunk(VoxelChunk original, Map 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 keys) { + if (keys.isEmpty()) { + input.bakeStatusMsg = "Keine Chunks markiert."; + return; + } + int restored = 0, noBackup = 0, failed = 0; + List 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 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 blurredMap) { try { 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 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()); } log.debug("Chunk ({},{},{}) gebacken.", original.cx, original.cy, original.cz); + return true; } catch (Exception e) { log.error("Bake fehlgeschlagen ({},{},{}): {}", 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; } + /** 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) { return switch (mode) { 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); 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 my = input.mouseScreenY; if (mx < 0) { @@ -2351,8 +2591,6 @@ public class VoxelEditorState extends BaseAppState { basePlane.setCullHint(entered ? Spatial.CullHint.Inherit : Spatial.CullHint.Always); if (chunkGrid != null) 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. } diff --git a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java index 37df745..96759a7 100644 --- a/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java +++ b/blight-editor/src/main/java/de/blight/editor/tool/VoxelTool.java @@ -31,7 +31,7 @@ public class VoxelTool extends EditorTool { public final ChoiceToolParameter mode = new ChoiceToolParameter( "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, new String[]{ "img/editor/terraintool_sinus.png", diff --git a/blight-map/src/main/map/blight_grass.blg b/blight-map/src/main/map/blight_grass.blg index 4b02cd8..07fba3f 100644 Binary files a/blight-map/src/main/map/blight_grass.blg and b/blight-map/src/main/map/blight_grass.blg differ diff --git a/blight-map/src/main/map/blight_grass_vertex.blgv b/blight-map/src/main/map/blight_grass_vertex.blgv index 470af62..7b76825 100644 Binary files a/blight-map/src/main/map/blight_grass_vertex.blgv and b/blight-map/src/main/map/blight_grass_vertex.blgv differ diff --git a/blight-map/src/main/map/blight_map.blm b/blight-map/src/main/map/blight_map.blm index 4fde61f..5f6db40 100644 Binary files a/blight-map/src/main/map/blight_map.blm and b/blight-map/src/main/map/blight_map.blm differ diff --git a/blight-map/src/main/map/blight_stones.bls b/blight-map/src/main/map/blight_stones.bls index 0aec51e..699adf7 100644 Binary files a/blight-map/src/main/map/blight_stones.bls and b/blight-map/src/main/map/blight_stones.bls differ diff --git a/blight-map/src/main/map/blight_voxel_splat.bin b/blight-map/src/main/map/blight_voxel_splat.bin index 59fe886..4c79678 100644 Binary files a/blight-map/src/main/map/blight_voxel_splat.bin and b/blight-map/src/main/map/blight_voxel_splat.bin differ diff --git a/blight-map/src/main/map/chunks/chunk_14_05.blc b/blight-map/src/main/map/chunks/chunk_14_05.blc index 69692dd..b09ee5d 100644 Binary files a/blight-map/src/main/map/chunks/chunk_14_05.blc and b/blight-map/src/main/map/chunks/chunk_14_05.blc differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o deleted file mode 100644 index e5d3137..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o deleted file mode 100644 index 6b51b7f..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o deleted file mode 100644 index e6579da..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_05_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_06.blvc b/blight-map/src/main/map/chunks/voxel_14_0_06.blvc deleted file mode 100644 index 48deeeb..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_06.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_06.blvc.prebake b/blight-map/src/main/map/chunks/voxel_14_0_06.blvc.prebake new file mode 100644 index 0000000..0a0848f Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_06.blvc.prebake differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod0.j3o new file mode 100644 index 0000000..ee44555 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod1.j3o new file mode 100644 index 0000000..35e084c Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_06_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_05.blvc b/blight-map/src/main/map/chunks/voxel_14_0_07.blvc.prebake similarity index 51% rename from blight-map/src/main/map/chunks/voxel_14_0_05.blvc rename to blight-map/src/main/map/chunks/voxel_14_0_07.blvc.prebake index 7e1d89b..fa3cdd3 100644 Binary files a/blight-map/src/main/map/chunks/voxel_14_0_05.blvc and b/blight-map/src/main/map/chunks/voxel_14_0_07.blvc.prebake differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod0.j3o new file mode 100644 index 0000000..248ee25 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod1.j3o new file mode 100644 index 0000000..027763b Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_14_0_07_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o deleted file mode 100644 index f78bf3b..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod0.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o deleted file mode 100644 index 401f061..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod1.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o b/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o deleted file mode 100644 index 90d5dd3..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_14_m1_05_baked_lod2.j3o and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_05.blvc b/blight-map/src/main/map/chunks/voxel_15_0_05.blvc deleted file mode 100644 index abc1f52..0000000 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_05.blvc and /dev/null differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_06.blvc b/blight-map/src/main/map/chunks/voxel_15_0_06.blvc index cf33ddd..d6315c5 100644 Binary files a/blight-map/src/main/map/chunks/voxel_15_0_06.blvc and b/blight-map/src/main/map/chunks/voxel_15_0_06.blvc differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_06.blvc.prebake b/blight-map/src/main/map/chunks/voxel_15_0_06.blvc.prebake new file mode 100644 index 0000000..424cf01 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_06.blvc.prebake differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod0.j3o new file mode 100644 index 0000000..a9ffc54 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod1.j3o new file mode 100644 index 0000000..006c906 Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_06_baked_lod1.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_07.blvc.prebake b/blight-map/src/main/map/chunks/voxel_15_0_07.blvc.prebake new file mode 100644 index 0000000..b0d474a Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_07.blvc.prebake differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod0.j3o b/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod0.j3o new file mode 100644 index 0000000..1128fed Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod0.j3o differ diff --git a/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod1.j3o b/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod1.j3o new file mode 100644 index 0000000..c2d18fa Binary files /dev/null and b/blight-map/src/main/map/chunks/voxel_15_0_07_baked_lod1.j3o differ