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.
* Gibt leere Liste zurück wenn kein Chunks-Verzeichnis existiert.

View File

@@ -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;
}

View File

@@ -262,6 +262,10 @@ public class SharedInput {
public record ObjectDrag(float dx, float dy) {}
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. */
// 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<Long> 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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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 ────────────────────────────────────────────────────

View File

@@ -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<Long> 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<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 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};
}
}

View File

@@ -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);

View File

@@ -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<Long, Geometry> 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<Long> 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<Long> 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<Long> visited = new HashSet<>();
Queue<long[]> 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<Long> 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<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
Map<Long, VoxelChunk> 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<VoxelChunk> 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<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 {
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.
}

View File

@@ -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",