Compare commits

...

3 Commits

30 changed files with 555 additions and 129 deletions

View File

@@ -14,6 +14,7 @@ import de.blight.eztree.Billboard;
import de.blight.eztree.TreeOptions;
import de.blight.eztree.TreePresets;
import de.blight.eztree.TreeType;
import javafx.embed.swing.SwingFXUtils;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.geometry.Insets;
@@ -799,10 +800,15 @@ public class EditorApp extends Application {
}
// Kamera-Koordinaten aktualisieren
camCoordsLabel.setText(String.format(
String camText = String.format(
"X:%.1f Y:%.1f Z:%.1f Yaw:%.0f° Pitch:%.0f°",
input.camX, input.camY, input.camZ,
input.camYaw, input.camPitch));
input.camYaw, input.camPitch);
float moh = input.mouseOverlayHeight;
if (input.debugHeightOverlayEnabled && !Float.isNaN(moh)) {
camText += String.format(java.util.Locale.US, " H:%.2fm", moh);
}
camCoordsLabel.setText(camText);
// Konsolen-Antwort anzeigen
String consoleMsg = input.consoleOutput;
@@ -1318,9 +1324,12 @@ public class EditorApp extends Application {
importTexSetItem.setOnAction(e -> openTextureSetImport(primaryStage));
importAudioItem.setOnAction(e -> handleAudioImport(primaryStage));
importAnimItem.setOnAction(e -> handleAnimationImport(primaryStage));
MenuItem screenshotItem = new MenuItem("Screenshot");
screenshotItem.setOnAction(e -> takeEditorScreenshot());
fileMenu.getItems().addAll(newItem, saveItem, new SeparatorMenuItem(),
importModelLodItem, importTexItem, importTexZipItem, importTexSetItem,
importAudioItem, importAnimItem);
importAudioItem, importAnimItem,
new SeparatorMenuItem(), screenshotItem);
Menu toolsMenu = new Menu("Werkzeuge");
MenuItem vegetationsItem = new MenuItem("Vegetations Generator");
@@ -1359,9 +1368,9 @@ public class EditorApp extends Application {
Menu viewMenu = new Menu("Ansicht");
MenuItem resetCam = new MenuItem("Kamera zurücksetzen");
resetCam.setOnAction(e -> input.addMouseDelta(0, 0));
MenuItem viewTexture = new MenuItem("Textur (Ctrl+G)");
MenuItem viewWireframe = new MenuItem("Drahtgitter (Ctrl+G)");
viewTopologyItem = new CheckMenuItem("Topologie-Overlay (Ctrl+T)");
MenuItem viewTexture = new MenuItem("Textur (Alt+G)");
MenuItem viewWireframe = new MenuItem("Drahtgitter (Alt+G)");
viewTopologyItem = new CheckMenuItem("Topologie-Overlay (Alt+T)");
viewTexture.setOnAction(e -> {
wireframeActive = false;
input.wireframeRequest = 2;
@@ -1382,8 +1391,14 @@ public class EditorApp extends Application {
camOrbitItem.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeItem.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
CheckMenuItem debugHeightItem = new CheckMenuItem("Höhen-Overlay (Alt+H)");
debugHeightItem.setAccelerator(javafx.scene.input.KeyCombination.keyCombination("Alt+H"));
debugHeightItem.setOnAction(e ->
input.debugHeightOverlayEnabled = debugHeightItem.isSelected());
viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe,
new SeparatorMenuItem(), viewTopologyItem,
new SeparatorMenuItem(), debugHeightItem,
new SeparatorMenuItem(), camOrbitItem, camFreeItem);
Menu zeitMenu = new Menu("Zeit");
@@ -3456,12 +3471,47 @@ public class EditorApp extends Application {
Label name = new Label(param.getName());
name.setStyle("-fx-text-fill: #111111;");
Slider slider = new Slider(param.getMin(), param.getMax(), param.getValue());
slider.setShowTickMarks(true);
slider.setShowTickLabels(true);
slider.setMajorTickUnit((param.getMax() - param.getMin()) / 2);
slider.valueProperty().addListener((obs, oldV, newV) -> param.setValue(newV.doubleValue()));
panel.getChildren().add(new VBox(3, name, withField(slider, "%.3f")));
if (tool instanceof de.blight.editor.tool.VoxelTool vtR
&& param == vtR.brushRadius) {
// Ganzzahliger Slider: snapToTicks mit majorTickUnit=1 → nur ganze Zahlen
int initR = Math.max(1, (int) Math.round(param.getValue()));
Slider slider = new Slider(1, 30, initR);
slider.setShowTickMarks(true);
slider.setShowTickLabels(true);
slider.setMajorTickUnit(5);
slider.setMinorTickCount(4);
slider.setSnapToTicks(true);
slider.setBlockIncrement(1);
slider.valueProperty().addListener((obs, oldV, newV) ->
param.setValue(Math.round(newV.doubleValue())));
panel.getChildren().add(new VBox(3, name, withField(slider, "%.0f")));
} else if (tool instanceof de.blight.editor.tool.VoxelTool vtP
&& param == vtP.plateauTarget) {
// x.5-gestufter Slider: min=0.5, Schritt=1 → 0.5, 1.5, 2.5, …
double initSnap = Math.round(param.getValue() - 0.5) + 0.5;
initSnap = Math.max(0.5, Math.min(49.5, initSnap));
Slider slider = new Slider(0.5, 49.5, initSnap);
slider.setShowTickMarks(true);
slider.setShowTickLabels(true);
slider.setMajorTickUnit(5);
slider.setMinorTickCount(4);
slider.setSnapToTicks(true);
slider.setBlockIncrement(1.0);
slider.valueProperty().addListener((obs, oldV, newV) -> {
double sv = Math.round(newV.doubleValue() - 0.5) + 0.5;
param.setValue(sv);
});
panel.getChildren().add(new VBox(3, name, withField(slider, "%.1f")));
} else {
Slider slider = new Slider(param.getMin(), param.getMax(), param.getValue());
slider.setShowTickMarks(true);
slider.setShowTickLabels(true);
slider.setMajorTickUnit((param.getMax() - param.getMin()) / 2);
slider.valueProperty().addListener((obs, oldV, newV) -> param.setValue(newV.doubleValue()));
panel.getChildren().add(new VBox(3, name, withField(slider, "%.3f")));
}
}
// Textur-Slot-Konfigurator (nur beim TextureTool)
@@ -3620,8 +3670,36 @@ public class EditorApp extends Application {
selPoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
selPoller.play();
// ── Unterirdische Voxel bereinigen ──────────────────────────────────
Label cleanupStatus = new Label("");
cleanupStatus.setWrapText(true);
cleanupStatus.setStyle("-fx-text-fill: #555; -fx-font-size: 11;");
Button cleanupBtn = new Button("Unterirdische Voxel bereinigen");
cleanupBtn.setMaxWidth(Double.MAX_VALUE);
cleanupBtn.setOnAction(e -> {
input.cleanupUndergroundStatus = null;
input.cleanupUndergroundRequested = true;
cleanupBtn.setDisable(true);
cleanupStatus.setText("Läuft...");
});
javafx.animation.Timeline cleanupPoller = new javafx.animation.Timeline(
new javafx.animation.KeyFrame(javafx.util.Duration.millis(200), ev -> {
String msg = input.cleanupUndergroundStatus;
if (msg != null) {
input.cleanupUndergroundStatus = null;
cleanupStatus.setText(msg);
cleanupBtn.setDisable(false);
}
})
);
cleanupPoller.setCycleCount(javafx.animation.Animation.INDEFINITE);
cleanupPoller.play();
panel.getChildren().addAll(new Separator(), bakeBtn,
bakeBar, bakeBarLabel, bakeStatus,
new Separator(), cleanupBtn, cleanupStatus,
new Separator(), selInfoLabel, selHintLabel, deleteBtn, revertBtn);
}
}
@@ -5673,6 +5751,21 @@ public class EditorApp extends Application {
}
}
private void takeEditorScreenshot() {
WritableImage image = primaryStage.getScene().snapshot(null);
String timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"));
Path dir = BlightHome.resolve("screenshots");
try {
Files.createDirectories(dir);
File out = dir.resolve("editor_" + timestamp + ".png").toFile();
ImageIO.write(SwingFXUtils.fromFXImage(image, null), "PNG", out);
setStatus("Screenshot gespeichert: " + out.getPath());
} catch (IOException ex) {
setStatus("Screenshot fehlgeschlagen: " + ex.getMessage());
}
}
private void handleTextureImport(javafx.stage.Window owner) {
FileChooser fc = new FileChooser();
fc.setTitle("Texturen importieren (nicht-PNG wird automatisch konvertiert)");
@@ -8187,6 +8280,7 @@ public class EditorApp extends Application {
case E -> input.down = pressed;
case SHIFT -> input.shiftHeld = pressed;
case CONTROL -> input.ctrlHeld = pressed;
case ALT -> input.altHeld = pressed;
case ENTER -> {
if (pressed && input.activeLayer == SharedInput.LAYER_VOXEL_CLIFF) {
input.generateCliffVoxelsRequested = true;
@@ -8228,13 +8322,13 @@ public class EditorApp extends Application {
input.waterSampleHeightRequested = true;
}
case G -> {
if (pressed && input.ctrlHeld) {
if (pressed && input.altHeld) {
wireframeActive = !wireframeActive;
input.wireframeRequest = wireframeActive ? 1 : 2;
}
}
case T -> {
if (pressed && input.ctrlHeld && viewTopologyItem != null) {
if (pressed && input.altHeld && viewTopologyItem != null) {
boolean sel = !viewTopologyItem.isSelected();
viewTopologyItem.setSelected(sel);
input.topologyRequest = sel ? 1 : 2;

View File

@@ -60,9 +60,10 @@ public class SharedInput {
public final java.util.concurrent.atomic.AtomicInteger scrollAccum =
new java.util.concurrent.atomic.AtomicInteger();
// ── Shift / Ctrl ─────────────────────────────────────────────────────────
// ── Shift / Ctrl / Alt ───────────────────────────────────────────────────
public volatile boolean shiftHeld;
public volatile boolean ctrlHeld;
public volatile boolean altHeld;
// ── Debug-Toggle: Strg+F8 schaltet Raw-Texture-Modus (kein Lighting) ─────
public volatile boolean debugNoLightToggle;
@@ -185,6 +186,8 @@ public class SharedInput {
// ── Mausposition im Viewport (JavaFX-Pixel, -1 = außerhalb) ─────────────
public volatile float mouseScreenX = -1f;
public volatile float mouseScreenY = -1f;
/** JME → JavaFX: Höhe am Mauszeiger wenn Height-Overlay aktiv (NaN = unbekannt). */
public volatile float mouseOverlayHeight = Float.NaN;
// ── Speichern ─────────────────────────────────────────────────────────────
public volatile boolean saveRequested = false;
@@ -783,6 +786,9 @@ public class SharedInput {
/** JME → JFX: Status-Meldung nach Abschluss des Backens oder Löschens. */
public volatile String bakeStatusMsg = null;
/** JFX → JME: Höhen-Overlay ein/aus (Terrain / Voxel / Baked im 100m-Radius). */
public volatile boolean debugHeightOverlayEnabled = false;
/**
* Per BFS selektierte gebackene Chunks (chunkKey-kodiert).
* Thread-sicher; JME schreibt, JFX liest (nur size() für Anzeige).
@@ -795,6 +801,10 @@ public class SharedInput {
public volatile boolean deleteMarkedBakedRequested = false;
/** JFX → JME: selektierte Chunks aus Pre-Bake-Backup wiederherstellen. */
public volatile boolean revertMarkedBakedRequested = false;
/** JFX → JME: unterirdische Voxel-Spalten (Oberfläche < Basis-Terrain) löschen. */
public volatile boolean cleanupUndergroundRequested = false;
/** JME → JFX: Ergebnis der Unterirdisch-Bereinigung (null = läuft noch / nicht gestartet). */
public volatile String cleanupUndergroundStatus = null;
/** Terrain-Slot (0-7) für flache Voxel-Flächen, -1 = kein Slot. */
public volatile int voxelFlatSlot = -1;

View File

@@ -131,6 +131,23 @@ public class VoxelEditorState extends BaseAppState {
private Geometry brushIndicator;
// ── Höhen-Debug-Overlay ───────────────────────────────────────────────────
private Node debugPtsNode;
private com.jme3.font.BitmapFont debugFont;
private com.jme3.font.BitmapText debugHudText;
private java.util.List<com.jme3.font.BitmapText> debugLabelsTerrain = new java.util.ArrayList<>();
private java.util.List<com.jme3.font.BitmapText> debugLabelsVoxel = new java.util.ArrayList<>();
private java.util.List<com.jme3.font.BitmapText> debugLabelsBaked = new java.util.ArrayList<>();
private float[] debugLabelXZ; // [i*2]=x, [i*2+1]=z
private float[] debugLabelTH; // Terrain-Höhe
private float[] debugLabelVH; // Voxel-Höhe (NaN = kein Voxel)
private float[] debugLabelBH; // Baked-Höhe (NaN = kein Treffer)
private boolean debugOverlayActive = false;
private float debugCenterX = Float.NaN;
private float debugCenterZ = Float.NaN;
private int debugLastStep = -1;
// ── Basis-Terrain-Referenzebene (y = -10) ────────────────────────────────
/** Flache Referenzebene bei Welt-Y = -10; nur im LAYER_VOXEL sichtbar. */
@@ -231,9 +248,12 @@ public class VoxelEditorState extends BaseAppState {
protected void cleanup(Application app) {
executor.shutdownNow();
voxelRoot.removeFromParent();
if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (wireframeActive) applyWireframe(false);
if (brushIndicator != null) brushIndicator.removeFromParent();
if (basePlaneNode != null) basePlaneNode.removeFromParent();
if (debugPtsNode != null) debugPtsNode.removeFromParent();
if (debugHudText != null) debugHudText.removeFromParent();
clearDebugLabels();
if (wireframeActive) applyWireframe(false);
nodes.clear();
chunks.clear();
}
@@ -263,6 +283,25 @@ public class VoxelEditorState extends BaseAppState {
// Brush-Indikator immer aktualisieren (zeigen/verstecken je nach Layer)
updateBrushIndicator();
// Höhen-Overlay ein/ausschalten und aktualisieren
if (input.debugHeightOverlayEnabled != debugOverlayActive) {
debugOverlayActive = input.debugHeightOverlayEnabled;
applyDebugOverlay(debugOverlayActive);
}
if (debugOverlayActive) {
float cx = cam.getLocation().x, cz = cam.getLocation().z;
int step = debugLabelStep(cam.getLocation().y);
float halfStep = step / 2f;
if (Float.isNaN(debugCenterX)
|| Math.abs(cx - debugCenterX) > halfStep
|| Math.abs(cz - debugCenterZ) > halfStep
|| step != debugLastStep) {
debugCenterX = cx; debugCenterZ = cz; debugLastStep = step;
rebuildDebugGeometry(cx, cz);
}
refreshDebugHud();
}
// Bake angefordert?
if (input.bakeVoxelsRequested) {
input.bakeVoxelsRequested = false;
@@ -716,9 +755,12 @@ public class VoxelEditorState extends BaseAppState {
// gebakte Meshes nach Distanz priorisiert immer der korrekte Oberflächenpunkt.
if (!isHorizontal && isColumn && modeIdx == de.blight.editor.tool.VoxelTool.MODE_PLATEAU && lower) {
if (Float.isFinite(wy)) {
input.voxelTool.plateauTarget.setValue(wy);
// Auf x.5-Raster einrasten: MC-Oberfläche liegt ~0.5m über dem Zelldach,
// daher ist 1.5m, 2.5m, 3.5m usw. der korrekte Zielwert.
double snapped = Math.round(wy - 0.5) + 0.5;
input.voxelTool.plateauTarget.setValue(snapped);
input.voxelTool.plateauTargetChanged = true;
input.heightTool.plateauHeight.setValue(wy);
input.heightTool.plateauHeight.setValue(snapped);
input.heightTool.plateauHeightChanged = true;
}
return;
@@ -1112,6 +1154,21 @@ public class VoxelEditorState extends BaseAppState {
return 0f;
}
/** Höchste bekannte Oberfläche an (worldX, worldZ): max(Terrain, Voxel, Baked). */
public float heightAt(float worldX, float worldZ) {
float h = terrainH(worldX, worldZ);
float vh = columnTopWorldY(worldX, worldZ);
if (vh > h + 0.1f) h = vh;
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
if (smes != null) {
com.jme3.math.Ray r = new com.jme3.math.Ray(
new Vector3f(worldX, 500f, worldZ), new Vector3f(0f, -1f, 0f));
Vector3f[] hit = smes.raycastGeometryWithNormal(r);
if (hit != null && hit[0].y > h) h = hit[0].y;
}
return h;
}
private boolean hasTerrainMesh() {
return terrainEditorState != null || terrainQuad != null;
}
@@ -1201,18 +1258,34 @@ public class VoxelEditorState extends BaseAppState {
return results.getClosestCollision().getContactPoint();
}
/** Welt-Y des höchsten Solid-Voxels an (worldX, worldZ), oder Terrain-Höhe wenn keine Voxel. */
/**
* Interpolierte Welt-Y der Voxel-Oberfläche an (worldX, worldZ).
* Verwendet dieselbe Lineare Interpolation wie Marching Cubes (t = -d0 / (d1 - d0)),
* sodass der zurückgegebene Wert mit dem echten Mesh übereinstimmt.
* Gibt Terrain-Höhe zurück wenn keine Voxel vorhanden.
*/
public float columnTopWorldY(float worldX, float worldZ) {
int cx = VoxelChunk.worldXToCx(worldX);
int cz = VoxelChunk.worldZToCz(worldZ);
int lx = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldXToLocal(worldX, cx)));
int cx = VoxelChunk.worldXToCx(worldX);
int cz = VoxelChunk.worldZToCz(worldZ);
int lx = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldXToLocal(worldX, cx)));
int lzl = Math.max(0, Math.min(VoxelChunk.SIZE - 1, (int) VoxelChunk.worldZToLocal(worldZ, cz)));
for (int cy = 10; cy >= -2; cy--) {
VoxelChunk chunk = chunks.get(chunkKey(cx, cy, cz));
if (chunk == null || chunk.isEmpty()) continue;
for (int ly = VoxelChunk.SIZE - 1; ly >= 0; ly--) {
if (chunk.getDensity(lx, ly, lzl) > 0) {
return VoxelChunk.toWorldY(cy, ly);
float d0 = chunk.getDensity(lx, ly, lzl);
if (d0 > 0) {
// Density der Zelle darüber (ggf. nächster Chunk)
float d1;
if (ly + 1 < VoxelChunk.SIZE) {
d1 = chunk.getDensity(lx, ly + 1, lzl);
} else {
VoxelChunk above = chunks.get(chunkKey(cx, cy + 1, cz));
d1 = (above != null) ? above.getDensity(lx, 0, lzl) : -1f;
}
float t = (Math.abs(d1 - d0) < 0.001f) ? 0.5f
: Math.max(0f, Math.min(1f, -d0 / (d1 - d0)));
return VoxelChunk.toWorldY(cy, ly) + t;
}
}
}
@@ -1260,12 +1333,27 @@ public class VoxelEditorState extends BaseAppState {
}
// Terrain-Höhe als Fallback wenn noch keine Voxel in dieser Spalte
float th = currentTopLY < 0 ? terrainH(wx, wz) : Float.NaN;
float currentTopWY = currentTopLY >= 0
? VoxelChunk.toWorldY(cy, currentTopLY)
: th;
float diff = targetH - currentTopWY;
if (Math.abs(diff) < 0.5f) continue;
// MC-Oberfläche liegt ~0.5m über der obersten soliden Zelle.
// Ohne Offset pendelt das Tool: raise→3m, diff=-0.5→lower→2m, diff=+0.5→raise→...
// Für leere Spalten (Terrain-Fallback) reicht terrainH direkt; dort
// genügt 0.1m Schwelle, damit z.B. Terrain 2m → Ziel 2.5m zuverlässig greift.
float effectiveCurrentH;
float threshold;
if (currentTopLY >= 0) {
effectiveCurrentH = VoxelChunk.toWorldY(cy, currentTopLY) + 0.5f;
threshold = 0.5f;
} else {
effectiveCurrentH = th;
threshold = 0.1f;
}
float diff = targetH - effectiveCurrentH;
if (Math.abs(diff) < threshold) continue;
// Ziel-Zelle: floor(targetH) = oberste SOLIDE Zelle bei Ziel-Höhe.
// Raise/Lower nie über diese Zelle hinaus → verhindert Überschuss-Artefakte.
int targetLY = Math.max(0, Math.min(VoxelChunk.SIZE - 1,
(int) Math.floor(targetH - cy * (float) VoxelChunk.CELLS)));
if (diff > 0) {
// Erhöhen
@@ -1283,13 +1371,13 @@ public class VoxelEditorState extends BaseAppState {
continue;
}
}
int newTop = Math.min(VoxelChunk.SIZE - 1, startLY + step);
int newTop = Math.min(startLY + step, targetLY); // nie über Ziel-Zelle
for (int ly = startLY; ly <= newTop; ly++) {
chunk.setDensity(lx, ly, lz, (byte) 127);
}
} else {
if (currentTopLY < 0) continue;
int newTop = Math.max(0, currentTopLY - step);
int newTop = Math.max(currentTopLY - step, targetLY); // nie unter Ziel-Zelle
for (int ly = newTop + 1; ly <= currentTopLY; ly++) {
chunk.setDensity(lx, ly, lz, Byte.MIN_VALUE);
}
@@ -1490,6 +1578,235 @@ public class VoxelEditorState extends BaseAppState {
* Bäckt alle übergebenen Chunks: speichert .blvc, glättet die Dichte mit
* Nachbar-Lookup, erzeugt LOD0/1/2-Meshes und exportiert sie als .j3o.
*/
// ── Höhen-Debug-Overlay ───────────────────────────────────────────────────
private void applyDebugOverlay(boolean on) {
if (on) {
if (debugFont == null) {
debugFont = app.getAssetManager().loadFont("Interface/Fonts/Default.fnt");
}
if (debugPtsNode == null) {
debugPtsNode = new Node("debugHeightPts");
debugPtsNode.setShadowMode(RenderQueue.ShadowMode.Off);
}
if (debugHudText == null) {
debugHudText = new com.jme3.font.BitmapText(debugFont, false);
debugHudText.setSize(debugFont.getCharSet().getRenderedSize() * 1.4f);
debugHudText.setColor(com.jme3.math.ColorRGBA.Yellow);
}
app.getRootNode().attachChild(debugPtsNode);
app.getGuiNode().attachChild(debugHudText);
debugCenterX = Float.NaN; // erzwingt sofortigen Rebuild
} else {
if (debugPtsNode != null) debugPtsNode.removeFromParent();
if (debugHudText != null) debugHudText.removeFromParent();
clearDebugLabels();
}
}
/**
* Baut drei farbige Punkt-Wolken im 100m-Radius um (cx, cz):
* Grau = Basis-Terrain
* Cyan = Raw-Voxel-Oberfläche
* Orange = Gebackene Mesh-Oberfläche (4m-Raster, Downward-Raycast)
* Jeder Punkt sitzt an seiner echten Welt-Y-Position, sodass
* man von der Seite drei überlagerte Flächen sieht.
*/
private void rebuildDebugGeometry(float cx, float cz) {
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
clearDebugLabels();
int ls = debugLastStep;
int lr = debugLabelRadius(ls);
java.util.List<float[]> lPts = new java.util.ArrayList<>();
for (int dz = -lr; dz <= lr; dz += ls) {
for (int dx = -lr; dx <= lr; dx += ls) {
if (dx * dx + dz * dz > lr * lr) continue;
lPts.add(new float[]{cx + dx, cz + dz});
}
}
int ln = lPts.size();
debugLabelXZ = new float[ln * 2];
debugLabelTH = new float[ln];
debugLabelVH = new float[ln]; // NaN wenn kein Voxel
debugLabelBH = new float[ln];
com.jme3.math.ColorRGBA colTerrain = com.jme3.math.ColorRGBA.White;
com.jme3.math.ColorRGBA colVoxel = new com.jme3.math.ColorRGBA(0.4f, 0.8f, 1f, 1f);
com.jme3.math.ColorRGBA colBaked = new com.jme3.math.ColorRGBA(1f, 0.5f, 0.5f, 1f);
for (int i = 0; i < ln; i++) {
float lx = lPts.get(i)[0], lz = lPts.get(i)[1];
debugLabelXZ[i * 2] = lx;
debugLabelXZ[i * 2 + 1] = lz;
float th = terrainH(lx, lz);
float vh = columnTopWorldY(lx, lz);
debugLabelTH[i] = th;
debugLabelVH[i] = (vh > th + 0.1f) ? vh : Float.NaN; // NaN wenn kein Voxel
if (smes != null) {
com.jme3.math.Ray r = new com.jme3.math.Ray(
new Vector3f(lx, 500f, lz), new Vector3f(0f, -1f, 0f));
Vector3f[] hit = smes.raycastGeometryWithNormal(r);
debugLabelBH[i] = (hit != null) ? hit[0].y : Float.NaN;
} else {
debugLabelBH[i] = Float.NaN;
}
debugLabelsTerrain.add(makeLabelBt(colTerrain));
debugLabelsVoxel .add(makeLabelBt(colVoxel));
debugLabelsBaked .add(makeLabelBt(colBaked));
}
}
private int debugLabelStep(float camY) {
if (camY < 25f) return 1;
else if (camY < 70f) return 4;
else return 8;
}
private int debugLabelRadius(int step) {
if (step <= 1) return 30;
else if (step <= 4) return 60;
else return 120;
}
private com.jme3.font.BitmapText makeLabelBt(com.jme3.math.ColorRGBA color) {
com.jme3.font.BitmapText bt = new com.jme3.font.BitmapText(debugFont, false);
bt.setSize(11f);
bt.setColor(color);
bt.setText("");
bt.setCullHint(com.jme3.scene.Spatial.CullHint.Always);
app.getGuiNode().attachChild(bt);
return bt;
}
private void clearDebugLabels() {
for (com.jme3.font.BitmapText bt : debugLabelsTerrain) bt.removeFromParent();
for (com.jme3.font.BitmapText bt : debugLabelsVoxel) bt.removeFromParent();
for (com.jme3.font.BitmapText bt : debugLabelsBaked) bt.removeFromParent();
debugLabelsTerrain.clear();
debugLabelsVoxel .clear();
debugLabelsBaked .clear();
}
/**
* Erzeugt eine Geometrie aus kurzen vertikalen Tick-Linien (0.5m hoch) an jedem
* Probenpunkt. Mode.Lines macht die Ticks deutlich sichtbarer als einzelne Pixel.
* posList: [x0,y0,z0, x1,y1,z1, ...] Fußpunkte der Ticks.
*/
private Geometry makeTickCloud(String name, java.util.List<Float> posList,
com.jme3.math.ColorRGBA color) {
int n = posList.size() / 3;
// 2 Vertices pro Tick (Basis + Spitze), je 3 Positions-Floats
FloatBuffer posBuf = BufferUtils.createFloatBuffer(n * 6);
FloatBuffer colBuf = BufferUtils.createFloatBuffer(n * 8);
for (int i = 0; i < n; i++) {
float x = posList.get(i * 3);
float y = posList.get(i * 3 + 1);
float z = posList.get(i * 3 + 2);
posBuf.put(x).put(y).put(z); // Basis
posBuf.put(x).put(y + 0.5f).put(z); // Spitze
colBuf.put(color.r).put(color.g).put(color.b).put(color.a);
colBuf.put(color.r).put(color.g).put(color.b).put(color.a);
}
posBuf.rewind(); colBuf.rewind();
Mesh mesh = new Mesh();
mesh.setMode(Mesh.Mode.Lines);
mesh.setBuffer(VertexBuffer.Type.Position, 3, posBuf);
mesh.setBuffer(VertexBuffer.Type.Color, 4, colBuf);
mesh.updateBound();
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setBoolean("VertexColor", true);
mat.getAdditionalRenderState().setDepthTest(false);
Geometry geo = new Geometry(name, mesh);
geo.setMaterial(mat);
geo.setShadowMode(RenderQueue.ShadowMode.Off);
return geo;
}
/**
* Aktualisiert den HUD-Text mit den Höhen direkt unterhalb der Kamera.
* Position: oben links im Viewport.
*/
private void refreshDebugHud() {
float wx = cam.getLocation().x;
float wz = cam.getLocation().z;
float th = terrainH(wx, wz);
float vh = columnTopWorldY(wx, wz);
SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class);
String bakedLine = "";
if (smes != null) {
com.jme3.math.Ray ray = new com.jme3.math.Ray(
new Vector3f(wx, 500f, wz), new Vector3f(0f, -1f, 0f));
Vector3f[] hit = smes.raycastGeometryWithNormal(ray);
if (hit != null) {
bakedLine = String.format(java.util.Locale.US,
"\n Baked (orange): %.2fm", hit[0].y);
}
}
debugHudText.setText(String.format(java.util.Locale.US,
"Höhen bei X=%.1f Z=%.1f\n" +
" Terrain (grau): %.2fm\n" +
" Voxel roh (cyan): %.2fm%s",
wx, wz, th, vh, bakedLine));
debugHudText.setLocalTranslation(8, cam.getHeight() - 8, 0);
// ── Projizierte Zahlenlabels ─────────────────────────────────────────
if (debugLabelXZ != null) {
int ln = debugLabelsTerrain.size();
for (int i = 0; i < ln; i++) {
float lx = debugLabelXZ[i * 2];
float lz = debugLabelXZ[i * 2 + 1];
projectLabel(debugLabelsTerrain.get(i), lx, debugLabelTH[i], lz,
String.format(java.util.Locale.US, "%.1f", debugLabelTH[i]));
float lvh = debugLabelVH[i];
if (!Float.isNaN(lvh)) {
projectLabel(debugLabelsVoxel.get(i), lx, lvh, lz,
String.format(java.util.Locale.US, "%.1f", lvh));
} else {
debugLabelsVoxel.get(i).setCullHint(com.jme3.scene.Spatial.CullHint.Always);
}
float lbh = debugLabelBH[i];
if (!Float.isNaN(lbh)) {
projectLabel(debugLabelsBaked.get(i), lx, lbh, lz,
String.format(java.util.Locale.US, "%.1f", lbh));
} else {
debugLabelsBaked.get(i).setCullHint(com.jme3.scene.Spatial.CullHint.Always);
}
}
}
// ── Mauszeiger-Höhe → SharedInput (echter Raycast gegen alle Geometrien) ──
float msx = input.mouseScreenX;
if (msx >= 0f) {
float msy = cam.getHeight() - input.mouseScreenY;
Hit mouseHit = raycastHit(msx, msy);
input.mouseOverlayHeight = (mouseHit != null && mouseHit.pos != null)
? mouseHit.pos.y : Float.NaN;
} else {
input.mouseOverlayHeight = Float.NaN;
}
}
private void projectLabel(com.jme3.font.BitmapText bt,
float wx, float wy, float wz, String text) {
Vector3f screen = cam.getScreenCoordinates(new Vector3f(wx, wy + 0.3f, wz));
boolean onScreen = screen.z > 0f && screen.z < 1f
&& screen.x > 0 && screen.x < cam.getWidth()
&& screen.y > 10 && screen.y < cam.getHeight();
if (onScreen) {
bt.setText(text);
bt.setLocalTranslation(screen.x, screen.y, 0);
bt.setCullHint(com.jme3.scene.Spatial.CullHint.Never);
} else {
bt.setCullHint(com.jme3.scene.Spatial.CullHint.Always);
}
}
private void bakeAll(List<VoxelChunk> toProcess) {
saveAll();
List<VoxelChunk> nonEmpty = new java.util.ArrayList<>();
@@ -1595,18 +1912,22 @@ public class VoxelEditorState extends BaseAppState {
// ── Schritt 1: Höhenfeld ──────────────────────────────────────────
// Key-Schema: (cx*C + bx + 30000) * 60001 + (cz*C + bz + 30000)
// Überlapp-Voxel (bx = CELLS) werden ausgelassen.
Map<Long, Integer> hfMap = new HashMap<>();
Map<Long, Float> hfMap = new HashMap<>();
for (VoxelChunk c : nonEmpty) {
long k = chunkKey(c.cx, c.cy, c.cz);
float[] buf = curBufs.get(k);
for (int bz = 0; bz < C; bz++) {
for (int bx = 0; bx < C; bx++) {
for (int by = blurN - 1; by >= 0; by--) {
if (buf[c.idx(bx, by, bz)] > 0) {
int wiy = c.cy * C + by;
float d0 = buf[c.idx(bx, by, bz)];
if (d0 > 0) {
float d1 = (by + 1 < blurN) ? buf[c.idx(bx, by + 1, bz)] : -1f;
float t = (Math.abs(d1 - d0) < 0.001f) ? 0.5f
: Math.max(0f, Math.min(1f, -d0 / (d1 - d0)));
float wfy = c.cy * C + by + t;
long hk = (long)(c.cx * C + bx + 30000) * 60001L
+ (c.cz * C + bz + 30000);
hfMap.merge(hk, wiy, Math::max);
hfMap.merge(hk, wfy, Math::max);
break;
}
}
@@ -1631,11 +1952,16 @@ public class VoxelEditorState extends BaseAppState {
for (int bz = 0; bz < C; bz++) {
for (int bx = 0; bx < C; bx++) {
for (int by = VoxelChunk.SIZE - 1; by >= 0; by--) {
if (nc.getDensity(bx, by, bz) > 0) {
int wiy = ncy * C + by;
float d0 = nc.getDensity(bx, by, bz);
if (d0 > 0) {
float d1 = (by + 1 < VoxelChunk.SIZE)
? nc.getDensity(bx, by + 1, bz) : -1f;
float t = (Math.abs(d1 - d0) < 0.001f) ? 0.5f
: Math.max(0f, Math.min(1f, -d0 / (d1 - d0)));
float wfy = ncy * C + by + t;
long hk = (long)(ncx * C + bx + 30000) * 60001L
+ (ncz * C + bz + 30000);
hfMap.merge(hk, wiy, Math::max);
hfMap.merge(hk, wfy, Math::max);
break;
}
}
@@ -1690,9 +2016,9 @@ public class VoxelEditorState extends BaseAppState {
// kleinsten Höhe > h0 bzw. größten Höhe < h0 wählen. Betraf 17 von 10002 Spalten
// in den Test-Chunks, max. Abweichung 1,0 (diag_realwall*.jsh im scratchpad).
Map<Long, Float> smoothMap = new HashMap<>(hfMap.size());
for (Map.Entry<Long, Integer> entry : hfMap.entrySet()) {
long hk0 = entry.getKey();
int h0 = entry.getValue();
for (Map.Entry<Long, Float> entry : hfMap.entrySet()) {
long hk0 = entry.getKey();
float h0 = entry.getValue();
int colZ = (int)(hk0 % 60001L) - 30000;
int colX = (int)(hk0 / 60001L) - 30000;
@@ -1706,15 +2032,15 @@ public class VoxelEditorState extends BaseAppState {
// mit der KLEINSTEN Höhe > h0 (bzw. GRÖSSTEN Höhe < h0) wählen, also den
// nächstgelegenen tatsächlichen Stufen-Nachbarn, nicht irgendeinen weiter
// entfernten. Validiert gegen reale Chunk-Daten (diag_realwall*.jsh).
Integer hUp = null; int dUp = -1;
Integer hDown = null; int dDownRaw = -1;
Float hUp = null; int dUp = -1;
Float hDown = null; int dDownRaw = -1;
for (int r = 1; r <= RADIUS && (hUp == null || hDown == null); r++) {
Integer bestUpThisRing = null, bestDownThisRing = null;
Float bestUpThisRing = null, bestDownThisRing = null;
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) != r) continue;
long hkN = (long)(colX + dx + 30000) * 60001L + (colZ + dz + 30000);
Integer hN = hfMap.get(hkN);
Float hN = hfMap.get(hkN);
if (hN == null) continue;
if (hN > h0 && (bestUpThisRing == null || hN < bestUpThisRing)) bestUpThisRing = hN;
if (hN < h0 && (bestDownThisRing == null || hN > bestDownThisRing)) bestDownThisRing = hN;
@@ -1739,10 +2065,15 @@ public class VoxelEditorState extends BaseAppState {
// Rand hin auslaufende Rampe über die vollen RADIUS Meter, statt eines
// abrupten Sprungs zwischen "geramped" und "unverändert flach".
float smoothH;
if (hUp == null) {
smoothH = h0; // Plateau: kein höherer Nachbar -> bewusst flach (Originalregel)
if (hUp == null || hDown == null) {
// Plateau (kein höherer Nachbar) ODER unterste/äußerste Ebene
// (kein niedrigerer Voxel-Nachbar): Höhe unverändert lassen.
// Die Formel darf hier NICHT angewendet werden sie würde die
// unterste Terrasse künstlich in Richtung hUp anheben und das
// gebackene Terrain systematisch zu hoch machen.
smoothH = h0;
} else {
float dDown = (hDown != null) ? (dDownRaw - 1) : (RADIUS - 1);
float dDown = dDownRaw - 1;
smoothH = (dDown <= 0f) ? h0 : (h0 * dUp + hUp * dDown) / (dUp + dDown);
}
smoothMap.put(hk0, smoothH);
@@ -1780,89 +2111,80 @@ public class VoxelEditorState extends BaseAppState {
// Fix v6 (aktuell): Steigungs-Fortsetzung komplett gestrichen. Reiner Distanz-Blend
// von der (ring-gemittelten) Kanten-Höhe zur tatsächlichen Basisterrain-Höhe. Folgt
// nicht mehr exakt dem Rampen-Winkel, ist aber garantiert monoton/glatt.
int[][] DIRS8 = {{1,0},{-1,0},{0,1},{0,-1},{1,1},{1,-1},{-1,1},{-1,-1}};
// 4:1-Abwärtsrampe bei Stufenabbrüchen (inter-terrace + Basisterrain-Übergang).
//
// Schritt 2 lässt Spalten mit hUp=null flach (smoothH=h0), auch wenn direkt
// daneben eine niedrigere Terasse oder das Basisterrain-Tal liegt. Das erzeugt
// vertikale Klippen an allen Plateau-Kanten.
//
// Fix: Für jede Spalte den nächsten Nachbarn mit Höhe < h0 suchen
// (Voxel-Spalte aus hfMap ODER Basisterrain außerhalb). Wenn gefunden bei
// Chebyshev-Distanz d mit mittlerer Höhe avgLow:
// rampH = avgLow + d / 4.0
// Spalte wird auf min(smoothH, rampH) abgesenkt nie angehoben.
// Entspricht 4 Schritte vor / 1 Schritt runter, deckt beide Bedingungen ab:
// „Tal darunter" (avgLow << h0) und „<1m über Basisterrain" (kleines dH).
// Ringsuche je Spalte unabhängig von anderen Schritt-2b-Ergebnissen →
// keine Kettenfortpflanzung, keine Divergenz.
{
final float RAMP_RATIO = 4.0f;
final int MAX_RAMP_R = 20;
// Kandidaten sammeln: alle leeren Zellen innerhalb RADIUS einer Rand-Spalte.
java.util.Set<Long> candidates = new java.util.HashSet<>();
for (long hk0 : hfMap.keySet()) {
int colZ = (int)(hk0 % 60001L) - 30000;
int colX = (int)(hk0 / 60001L) - 30000;
for (int dz = -RADIUS; dz <= RADIUS; dz++) {
for (int dx = -RADIUS; dx <= RADIUS; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) > RADIUS) continue;
long hkC = (long)(colX + dx + 30000) * 60001L + (colZ + dz + 30000);
if (!hfMap.containsKey(hkC)) candidates.add(hkC);
}
}
}
// Terrain-Cache: vermeidet wiederholte terrainH()-Aufrufe bei Überlappungen
Map<Long, Float> terrainCache2b = new HashMap<>();
Map<Long, Float> extensionMap = new HashMap<>();
int extCount = 0, extLogged = 0;
for (long hkC : candidates) {
int colZ = (int)(hkC % 60001L) - 30000;
int colX = (int)(hkC / 60001L) - 30000;
for (Map.Entry<Long, Float> entry : hfMap.entrySet()) {
long hk0 = entry.getKey();
float h0raw = entry.getValue(); // Original-Voxelhöhe
float h0sm = smoothMap.getOrDefault(hk0, h0raw); // Schritt-2-Ausgabe
int colX2b = (int)(hk0 / 60001L) - 30000;
int colZ2b = (int)(hk0 % 60001L) - 30000;
// Nächsten Ring mit Struktur-Zellen isotrop suchen (gleiches Vorgehen wie
// Schritt 2), dann ALLE Struktur-Zellen in diesem Ring mitteln nicht nur die
// erste gefundene. Grund (Log-Befund): benachbarte Zielzellen fanden oft
// unterschiedliche einzelne "nächste" Zellen mit stark abweichender Höhe
// (edgeH schwankte 3,9 bis 5,33 nur 4 Zellen auseinander) → wildes Springen.
// Mittelung über den ganzen Ring glättet das.
int nearestDist = -1;
java.util.List<long[]> ringHits = new java.util.ArrayList<>(); // {colX,colZ}
outer:
for (int r = 1; r <= RADIUS; r++) {
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) != r) continue;
long hkN = (long)(colX + dx + 30000) * 60001L + (colZ + dz + 30000);
if (hfMap.containsKey(hkN)) ringHits.add(new long[]{colX + dx, colZ + dz});
// Ringsuche nach nächstem niedrigeren Nachbarn (Voxel oder Terrain)
float sumLowH = 0f;
int lowCount = 0;
int lowDist = -1;
outer2b:
for (int r = 1; r <= MAX_RAMP_R; r++) {
for (int dz2b = -r; dz2b <= r; dz2b++) {
for (int dx2b = -r; dx2b <= r; dx2b++) {
if (Math.max(Math.abs(dx2b), Math.abs(dz2b)) != r) continue;
long hkN = (long)(colX2b + dx2b + 30000) * 60001L
+ (colZ2b + dz2b + 30000);
Float hN = hfMap.get(hkN);
float neighborH;
if (hN != null) {
neighborH = hN; // Voxel-Spalte: Originalhöhe
} else {
Float cached = terrainCache2b.get(hkN);
if (cached == null) {
cached = terrainH((float)(colX2b + dx2b),
(float)(colZ2b + dz2b));
terrainCache2b.put(hkN, cached);
}
neighborH = cached; // Basisterrain
}
if (neighborH < h0raw) {
sumLowH += neighborH;
lowCount++;
}
}
}
if (lowCount > 0) {
lowDist = r;
break outer2b;
}
}
if (!ringHits.isEmpty()) { nearestDist = r; break outer; }
}
if (nearestDist < 0) continue; // außerhalb der Reichweite, bleibt Klippe
float sumEdgeH = 0f; int hitCnt = 0;
for (long[] hit : ringHits) {
int hColX = (int) hit[0], hColZ = (int) hit[1];
long hKey = (long)(hColX + 30000) * 60001L + (hColZ + 30000);
Float hH = smoothMap.get(hKey);
if (hH == null) continue;
sumEdgeH += hH; hitCnt++;
}
if (hitCnt == 0) continue;
float edgeH = sumEdgeH / hitCnt;
if (lowDist < 0) continue; // kein niedrigerer Nachbar im Suchradius
// KEINE Steigungs-Extrapolation mehr (v4/v5 hatten das versucht eine an nur
// wenigen Pixeln gemessene Steigung ist zu verrauscht, verstärkt sich über die
// Distanz und erzeugte ein welliges/oszillierendes Band über eigentlich flachen
// Flächen, siehe Screenshot 22-43-57). Reiner, robuster Distanz-Blend von der
// (gemittelten) Kanten-Höhe zur tatsächlichen Basisterrain-Höhe: nah am Rand
// dominiert die Kante, weiter draußen die Terrainhöhe. Folgt nicht mehr exakt
// dem Winkel der letzten Rampe, ist aber garantiert monoton und ohne Rauschen.
float wx = colX - 2048f, wz = colZ - 2048f;
float th = terrainH(wx, wz);
float finalH = edgeH;
if (Float.isFinite(th)) {
float t = nearestDist / (float) RADIUS; // 0 am Rand, 1 bei RADIUS
finalH = edgeH * (1f - t) + th * t;
float avgLowH = sumLowH / lowCount;
float rampH = avgLowH + lowDist / RAMP_RATIO;
if (rampH < h0sm) {
smoothMap.put(hk0, rampH);
}
}
extensionMap.put(hkC, finalH);
extCount++;
if (extLogged < 20) {
log.info("Perimeter-Rampe: colX={} colZ={} dist={} edgeH={} th={} finalH={}",
colX, colZ, nearestDist, edgeH, th, finalH);
extLogged++;
}
}
log.info("Perimeter-Übergang: {} Erweiterungs-Spalten jenseits des Strukturrands erzeugt.", extCount);
// Erweiterungs-Spalten in hfMap/smoothMap einspeisen, damit Schritt 3 sie mitschreibt.
for (Map.Entry<Long, Float> e : extensionMap.entrySet()) {
long hk = e.getKey();
if (hfMap.containsKey(hk)) continue;
hfMap.put(hk, Math.round(e.getValue()));
smoothMap.put(hk, e.getValue());
}
// ── Schritt 3: Dichte anpassen ────────────────────────────────────
@@ -1889,7 +2211,7 @@ public class VoxelEditorState extends BaseAppState {
for (int bx = 0; bx < C; bx++) {
int colX = c.cx * C + bx;
long hk0 = (long)(colX + 30000) * 60001L + (colZ + 30000);
Integer h0 = hfMap.get(hk0);
Float h0 = hfMap.get(hk0);
Float smoothH = smoothMap.get(hk0);
if (h0 == null || smoothH == null) continue;

View File

@@ -25,7 +25,7 @@ public class HeightTool extends EditorTool {
}
);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 50.0, 1.0, 500.0);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 500.0);
public final ToolParameter brushStrength = new ToolParameter("Pinselstärke", 2.0, 0.1, 50.0);
public final ToolParameter plateauHeight = new ToolParameter("Plateau-Höhe", 0.0, -500.0, 500.0);
public volatile boolean plateauHeightChanged = false;

View File

@@ -47,9 +47,9 @@ public class VoxelTool extends EditorTool {
public volatile boolean modeChanged = false;
public volatile boolean horizontal = false;
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 0.5, 30.0);
public final ToolParameter brushRadius = new ToolParameter("Pinselradius", 5.0, 1.0, 30.0);
public final ToolParameter brushStrength = new ToolParameter("Stärke", 20.0, 1.0, 80.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 0.0, -200.0, 500.0);
public final ToolParameter plateauTarget = new ToolParameter("Plateau-Ziel", 1.5, -99.5, 499.5);
public volatile boolean plateauTargetChanged = false;
@Override public String getName() { return "Voxel"; }