Saubere Kante beim Voxel backen, Höhenindikator hinzugefügt

This commit is contained in:
2026-08-08 13:05:54 +02:00
parent 8feaf24490
commit 3b7f3b7e86
24 changed files with 390 additions and 40 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");
@@ -5673,6 +5688,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 +8217,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 +8259,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).

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;
@@ -1112,6 +1151,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 +1255,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;
}
}
}
@@ -1490,6 +1560,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 +1894,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 +1934,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 +1998,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 +2014,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;
@@ -1740,9 +2048,13 @@ public class VoxelEditorState extends BaseAppState {
// abrupten Sprungs zwischen "geramped" und "unverändert flach".
float smoothH;
if (hUp == null) {
smoothH = h0; // Plateau: kein höherer Nachbar -> bewusst flach (Originalregel)
smoothH = h0; // Plateau
} else if (hDown == null) {
// Kein niedrigerer Voxel-Nachbar: obere Rampe zum Rand hin auslaufen lassen.
float dDown = RADIUS - 1;
smoothH = (h0 * dUp + hUp * dDown) / (dUp + dDown);
} 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);
@@ -1856,14 +2168,15 @@ public class VoxelEditorState extends BaseAppState {
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()) {
log.info("Perimeter-Übergang: {} Erweiterungs-Spalten berechnet (aktuell deaktiviert saubere Kante).", extCount);
// Erweiterungs-Spalten DEAKTIVIERT: liefert saubere Kante an der Voxel-Grenze
// statt der problematischen flachen Rampe. Zum Reaktivieren diesen Block einkommentieren:
/* for (Map.Entry<Long, Float> e : extensionMap.entrySet()) {
long hk = e.getKey();
if (hfMap.containsKey(hk)) continue;
hfMap.put(hk, Math.round(e.getValue()));
hfMap.put(hk, e.getValue());
smoothMap.put(hk, e.getValue());
}
} */
// ── Schritt 3: Dichte anpassen ────────────────────────────────────
// ALLE Spalten aus hfMap bekommen den Dichte-Gradienten geschrieben, auch
@@ -1889,7 +2202,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;