Commit vor dem Urlaub
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user