diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 3513aa4..b16c189 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -5705,8 +5705,8 @@ public class EditorApp extends Application { if (!isSelected) return; if (!worldMapView.isLoaded()) { worldMapView.loadAndRender(); - } else if (caveMapView != null && caveMapView.isLoaded()) { - // Sync nach Layout-Pass damit Canvas-Größe korrekt ist + } else if (caveMapView != null && caveMapView.isLoaded() && caveMapView.isViewInitialized()) { + // Sync nur wenn CaveMap korrekt initialisiert (canvas > 0, zoomLevels gesetzt) final double s = caveMapView.getScale(), px = caveMapView.getPanX(), py = caveMapView.getPanY(); javafx.application.Platform.runLater(() -> worldMapView.setView(s, px, py)); } else { @@ -5721,8 +5721,8 @@ public class EditorApp extends Application { if (!isSelected) return; if (!caveMapView.isLoaded()) { caveMapView.loadAndRender(); - } else if (worldMapView.isMapReady()) { - // Sync nach Layout-Pass damit Canvas-Größe korrekt ist + } else if (worldMapView.isMapReady() && worldMapView.isViewInitialized()) { + // Sync nur wenn WorldMap korrekt initialisiert final double s = worldMapView.getScale(), px = worldMapView.getPanX(), py = worldMapView.getPanY(); javafx.application.Platform.runLater(() -> caveMapView.setView(s, px, py)); } else { diff --git a/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java index 1eccfe7..1e56d7b 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/CaveMapView.java @@ -73,7 +73,6 @@ public class CaveMapView extends VBox { private double panX = 0, panY = 0, scale = 1.0; private double[] zoomLevels = null; private int zoomLevelIdx = 0; - private boolean autoLoaded = false; public CaveMapView(Supplier stageSupplier) { this.stageSupplier = stageSupplier; @@ -121,7 +120,7 @@ public class CaveMapView extends VBox { backBtn ); - progress.setMaxWidth(Double.MAX_VALUE); + progress.setPrefWidth(160); progress.setVisible(false); canvasPane.getChildren().add(canvas); @@ -132,11 +131,15 @@ public class CaveMapView extends VBox { canvas.widthProperty().addListener(obs -> redraw()); canvas.heightProperty().addListener(obs -> redraw()); - statusLbl.setPadding(new Insets(4, 8, 4, 8)); + // Status-Leiste identisch wie WorldMapView: unter dem Canvas + HBox statusBar = new HBox(8, statusLbl, progress); + statusBar.setAlignment(javafx.geometry.Pos.CENTER_LEFT); + statusBar.setPadding(new Insets(4, 8, 4, 8)); + statusBar.setStyle("-fx-background-color: #2a2a3a;"); setupMouse(); - getChildren().addAll(bar, progress, statusLbl, canvasPane); + getChildren().addAll(bar, canvasPane, statusBar); setStyle("-fx-background-color: #1a1a1a;"); } @@ -262,6 +265,8 @@ public class CaveMapView extends VBox { } public boolean isLoaded() { return mapFxImage != null; } + /** True wenn Zoom-Levels korrekt initialisiert (Canvas hatte Größe > 0 beim Laden). */ + public boolean isViewInitialized() { return zoomLevels != null; } public void ensureFitToView() { if (mapFxImage != null) fitToView(); } @@ -292,10 +297,11 @@ public class CaveMapView extends VBox { Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); boolean[] caveMask = WorldMapView.loadCaveMask(mapData); - // Pfade: Y-basierte Trennung (voxelSurface > path_y + clearance) + // Pfade: Sandwich+Y-basierte Trennung (echter Tunnel: solid→void≥3→solid) Platform.runLater(() -> statusLbl.setText("Berechne VoxelSurface…")); float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData); - List[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface); + boolean[] sandwichMask = WorldMapView.bakeSandwichMask(); + List[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface, sandwichMask); List cavePaths = splitChains[1]; // Gebäude + Areas: Cave-Maske (keine Y-Info) @@ -367,9 +373,9 @@ public class CaveMapView extends VBox { } private void redraw() { - if (!autoLoaded && mapFxImage == null && !loading.get()) { - autoLoaded = true; - loadAndRender(); + // Nachgelagerte Initialisierung: Canvas hatte beim Laden evtl. Größe 0 + if (mapFxImage != null && zoomLevels == null) { + fitToView(); } GraphicsContext gc = canvas.getGraphicsContext2D(); double w = canvas.getWidth(), h = canvas.getHeight(); diff --git a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java index 92abb2b..e2b400d 100644 --- a/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java +++ b/blight-editor/src/main/java/de/blight/editor/ui/WorldMapView.java @@ -359,6 +359,9 @@ public class WorldMapView extends VBox { } } + /** True wenn Zoom-Levels korrekt initialisiert (Canvas hatte Größe > 0 beim Laden). */ + public boolean isViewInitialized() { return zoomLevels != null; } + /** Lädt alle Weltdaten neu und baut das Render-Modell (teuer: I/O + Berechnungen). */ public void loadAndRender() { if (loading.getAndSet(true)) return; @@ -377,7 +380,9 @@ public class WorldMapView extends VBox { List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); List waterfalls = WaterfallIO.load(); - float[] voxelSurf = bakeVoxelHeights(mapData); + VoxelData vd = bakeVoxelData(mapData); + float[] voxelSurf = vd.voxelSurface(); + boolean[] sandwichMask = vd.sandwichMask(); Platform.runLater(() -> statusLbl.setText("Berechne Modell…")); WorldMapRenderModel model = buildRenderModel( @@ -405,7 +410,7 @@ public class WorldMapView extends VBox { Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); boolean[] caveMask = loadCaveMask(model.mapData()); - List[] splitChains = splitPathChains(allPathChains, model.voxelSurface()); + List[] splitChains = splitPathChains(allPathChains, model.voxelSurface(), sandwichMask); List newPathChains = splitChains[0]; // nur Oberfläche List newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false); List surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false); @@ -1109,24 +1114,31 @@ public class WorldMapView extends VBox { } /** - * Teilt Pfad-Ketten an Tunnel-Grenzen auf (Y-basiert, präzise). - * Nutzt die Y-Koordinate der Pfad-Knoten (Index 2) und prüft ob sie unterhalb - * von solidem Voxel-Material liegen (voxelSurface > node_y + MIN_CEILING). + * Teilt Pfad-Ketten an Tunnel-Grenzen auf. + * Kombiniert Sandwich-Prüfung (solid→void≥3→solid = echter Tunnel) mit Y-Koordinate, + * um False-Positives an Klippen-Füßen zu vermeiden. * Rückgabe: Index 0 = Oberflächensegmente, Index 1 = Tunnel-Segmente. */ @SuppressWarnings("unchecked") - static List[] splitPathChains(List chains, float[] voxelSurface) { + static List[] splitPathChains(List chains, float[] voxelSurface, + boolean[] sandwichMask) { List surface = new ArrayList<>(); List cave = new ArrayList<>(); for (float[][] chain : chains) { - splitChainByVoxelSurface(chain, voxelSurface, surface, cave); + splitChainByVoxelSurface(chain, voxelSurface, sandwichMask, surface, cave); } return new List[]{surface, cave}; } private static final float PATH_UNDERGROUND_CLEARANCE = 3.0f; - private static boolean isNodeUnderground(float[] node, float[] voxelSurface) { + /** + * Prüft ob ein Knoten unter der Erde liegt. + * Bedingung: In dieser X,Z-Spalte liegt ein Sandwich (solid→void≥3→solid) + * UND das höchste Solid ist ≥ PATH_UNDERGROUND_CLEARANCE über der Knoten-Y. + * Das Sandwich-Kriterium verhindert False-Positives bei Wegen am Klippenfuß. + */ + private static boolean isNodeUnderground(float[] node, float[] voxelSurface, boolean[] sandwichMask) { if (node.length < 3) return false; float wy = node[2]; if (Float.isNaN(wy)) return false; @@ -1136,26 +1148,29 @@ public class WorldMapView extends VBox { int pz = Math.round((node[1] + WH) / WS * (VS - 1)); px = Math.max(0, Math.min(VS - 1, px)); pz = Math.max(0, Math.min(VS - 1, pz)); + // Sandwich-Prüfung: nur dort wo echter Hohlraum vorliegt (nicht bloß Klippe) + if (sandwichMask != null && !sandwichMask[pz * VS + px]) return false; float vs = voxelSurface[pz * VS + px]; return !Float.isNaN(vs) && vs > wy + PATH_UNDERGROUND_CLEARANCE; } private static void splitChainByVoxelSurface(float[][] chain, float[] voxelSurface, + boolean[] sandwichMask, List surface, List cave) { if (chain.length < 2) return; List current = new ArrayList<>(); - boolean currentCave = isNodeUnderground(chain[0], voxelSurface); + boolean currentCave = isNodeUnderground(chain[0], voxelSurface, sandwichMask); current.add(chain[0]); for (int i = 0; i < chain.length - 1; i++) { float[] a = chain[i]; float[] b = chain[i + 1]; - boolean bCave = isNodeUnderground(b, voxelSurface); + boolean bCave = isNodeUnderground(b, voxelSurface, sandwichMask); if (currentCave == bCave) { current.add(b); } else { - float[] crossing = findPathCrossing(a, b, voxelSurface, currentCave); + float[] crossing = findPathCrossing(a, b, voxelSurface, sandwichMask, currentCave); current.add(crossing); if (current.size() >= 2) { (currentCave ? cave : surface).add(current.toArray(new float[0][])); @@ -1172,14 +1187,15 @@ public class WorldMapView extends VBox { } /** Bisektion mit Y-Interpolation: 12 Iterationen ≈ 0.05m Genauigkeit. */ - private static float[] findPathCrossing(float[] a, float[] b, float[] vs, boolean aIsUnderground) { + private static float[] findPathCrossing(float[] a, float[] b, float[] vs, boolean[] sm, + boolean aIsUnderground) { float ax = a[0], az = a[1], ay = a.length > 2 ? a[2] : 0f; float bx = b[0], bz = b[1], by = b.length > 2 ? b[2] : 0f; for (int i = 0; i < 12; i++) { float mx = (ax + bx) * 0.5f, mz = (az + bz) * 0.5f, my = (ay + by) * 0.5f; float[] mid = {mx, mz, my}; - if (isNodeUnderground(mid, vs) == aIsUnderground) { ax = mx; az = mz; ay = my; } - else { bx = mx; bz = mz; by = my; } + if (isNodeUnderground(mid, vs, sm) == aIsUnderground) { ax = mx; az = mz; ay = my; } + else { bx = mx; bz = mz; by = my; } } return new float[]{(ax + bx) * 0.5f, (az + bz) * 0.5f, (ay + by) * 0.5f}; } @@ -1373,7 +1389,9 @@ public class WorldMapView extends VBox { return i; } - private float[] bakeVoxelHeights(MapData mapData) { + private record VoxelData(float[] voxelSurface, boolean[] sandwichMask) {} + + private VoxelData bakeVoxelData(MapData mapData) { int VS = WorldMapRenderer.VOXEL_SURFACE_RES; // Bevorzuge Live-Chunks aus dem Editor-Speicher, falle auf Disk zurück List chunks = voxelChunkSupplier != null @@ -1381,10 +1399,11 @@ public class WorldMapView extends VBox { : VoxelChunkIO.loadAll(); boolean allEmpty = chunks.stream().allMatch(VoxelChunk::isEmpty); if (allEmpty) { chunks = VoxelChunkIO.loadAll(); } + boolean[] sandwichMask = new boolean[VS * VS]; if (chunks.isEmpty()) { float[] empty = new float[VS * VS]; java.util.Arrays.fill(empty, Float.NaN); - return empty; + return new VoxelData(empty, sandwichMask); } // Kern-Logik ist dieselbe wie WorldMapRenderer.bakeVoxelSurface, aber mit // Live-Chunks statt VoxelChunkIO.loadAll(). @@ -1422,7 +1441,57 @@ public class WorldMapView extends VBox { } } } - return voxelSurface; + // Sandwich-Maske: pro Spalte prüfen ob solid→void(≥3)→solid vorliegt (echter Tunnel) + buildSandwichMaskInto(chunks, sandwichMask, VS, WH, WS); + return new VoxelData(voxelSurface, sandwichMask); + } + + /** + * Berechnet pro Voxel-Spalte, ob eine Tunnel-Struktur (solid→void≥3→solid) vorliegt. + * Nur cy=0-Chunks werden berücksichtigt; Klippen ohne Hohlraum werden NICHT markiert. + */ + private static void buildSandwichMaskInto(List chunks, boolean[] out, int VS, + float WH, float WS) { + for (VoxelChunk chunk : chunks) { + if (chunk.isEmpty() || chunk.cy != 0) continue; + for (int lz = 0; lz < VoxelChunk.SIZE; lz++) { + float worldZ = VoxelChunk.toWorldZ(chunk.cz, lz); + int vsPz = Math.round((worldZ + WH) / WS * (VS - 1)); + if (vsPz < 0 || vsPz >= VS) continue; + for (int lx = 0; lx < VoxelChunk.SIZE; lx++) { + float worldX = VoxelChunk.toWorldX(chunk.cx, lx); + int vsPx = Math.round((worldX + WH) / WS * (VS - 1)); + if (vsPx < 0 || vsPx >= VS) continue; + int idx = vsPz * VS + vsPx; + if (out[idx]) continue; + int phase = 0, voidCnt = 0; + for (int ly = 0; ly < VoxelChunk.SIZE; ly++) { + byte d = chunk.getDensity(lx, ly, lz); + if (phase == 0) { + if (d > 0) phase = 1; + } else if (phase == 1) { + if (d <= 0) { phase = 2; voidCnt = 1; } + } else { + if (d <= 0) { voidCnt++; } + else { + if (voidCnt >= 3) { out[idx] = true; break; } + phase = 1; voidCnt = 0; + } + } + } + } + } + } + } + + /** Wie buildSandwichMaskInto, aber lädt Chunks von Disk (für CaveMapView). */ + static boolean[] bakeSandwichMask() { + int VS = WorldMapRenderer.VOXEL_SURFACE_RES; + boolean[] sm = new boolean[VS * VS]; + List chunks; + try { chunks = VoxelChunkIO.loadAll(); } catch (Exception e) { return sm; } + buildSandwichMaskInto(chunks, sm, VS, WorldMapRenderer.WORLD_HALF, WorldMapRenderer.WORLD_SIZE); + return sm; } private WorldMapRenderModel buildRenderModel(