From f29580cb3bf6eebf109b67bcd150f4453574df97 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 7 Sep 2026 10:22:48 +0200 Subject: [PATCH] Karte: Tunnel-Rendering verbessert, Pfad-Erkennung robuster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pfad-Split: Sandwich-Maske (per-Spalte, fragil) ersetzt durch Chunk-Level-Cave-Maske + Y-Prüfung; erkennt schmalere Tunnel zuverlässig und vermeidet extra Disk-Scan - Weltkarte: Tunneleingang-Halbkreise ersetzt durch gefüllte Fläche (semi-transparent braun) + glatte Chaikin-Kontur (ähnlich Wasser-Rendering) - Höhlenkarte: smooth Kontur-Overlay (warmweiss) um die Tunnelfläche ergänzt, zusätzlich zu den D-Eingängen Co-Authored-By: Claude Sonnet 4.6 --- .../java/de/blight/editor/ui/CaveMapView.java | 55 +++++- .../de/blight/editor/ui/WorldMapView.java | 187 +++++++----------- 2 files changed, 118 insertions(+), 124 deletions(-) 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 1e56d7b..39dcbe4 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 @@ -63,12 +63,13 @@ public class CaveMapView extends VBox { private Runnable enterFullscreenCallback = null; private Runnable exitFullscreenCallback = null; - private WritableImage mapFxImage = null; - private List cachedPathChains = new ArrayList<>(); - private List tunnelEntrances = new ArrayList<>(); - private List cachedAreas = new ArrayList<>(); - private List buildingFootprints = new ArrayList<>(); - private MapData cachedMapData = null; // für Export + private WritableImage mapFxImage = null; + private List cachedPathChains = new ArrayList<>(); + private List tunnelEntrances = new ArrayList<>(); + private List tunnelContourPaths = new ArrayList<>(); + private List cachedAreas = new ArrayList<>(); + private List buildingFootprints = new ArrayList<>(); + private MapData cachedMapData = null; // für Export private double panX = 0, panY = 0, scale = 1.0; private double[] zoomLevels = null; @@ -297,11 +298,11 @@ public class CaveMapView extends VBox { Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); boolean[] caveMask = WorldMapView.loadCaveMask(mapData); - // Pfade: Sandwich+Y-basierte Trennung (echter Tunnel: solid→void≥3→solid) + // Pfade: Cave-Maske + Y-basierte Trennung Platform.runLater(() -> statusLbl.setText("Berechne VoxelSurface…")); float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData); - boolean[] sandwichMask = WorldMapView.bakeSandwichMask(); - List[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface, sandwichMask); + List[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface, caveMask); + List newContourPaths = WorldMapView.buildTunnelContourPaths(caveMask); List cavePaths = splitChains[1]; // Gebäude + Areas: Cave-Maske (keine Y-Info) @@ -312,6 +313,7 @@ public class CaveMapView extends VBox { mapFxImage = SwingFXUtils.toFXImage(bi, null); cachedPathChains = cavePaths; tunnelEntrances = entrances; + tunnelContourPaths = newContourPaths; cachedAreas = caveAreas; buildingFootprints = caveFootprints; cachedMapData = mapData; @@ -388,7 +390,10 @@ public class CaveMapView extends VBox { } gc.setImageSmoothing(true); gc.drawImage(mapFxImage, panX, panY, mapFxImage.getWidth() * scale, mapFxImage.getHeight() * scale); - if (cbEntrances.isSelected()) drawTunnelEntrancesOverlay(gc); + if (cbEntrances.isSelected()) { + drawTunnelContourOverlay(gc); + drawTunnelEntrancesOverlay(gc); + } if (cbBuildings.isSelected()) drawBuildingOverlay(gc); if (cbPaths.isSelected()) drawPathsOverlay(gc); if (cbAreas.isSelected()) drawAreasOverlay(gc); @@ -417,6 +422,36 @@ public class CaveMapView extends VBox { }; } + private void drawTunnelContourOverlay(GraphicsContext gc) { + if (tunnelContourPaths.isEmpty()) return; + double cW = canvas.getWidth(), cH = canvas.getHeight(); + gc.save(); + gc.setStroke(Color.rgb(220, 200, 160, 0.80)); + gc.setLineWidth(overlayLineWidth() + 0.5); + gc.setLineDashes(); + gc.setLineCap(javafx.scene.shape.StrokeLineCap.ROUND); + gc.setLineJoin(javafx.scene.shape.StrokeLineJoin.ROUND); + for (float[][] path : tunnelContourPaths) drawCavePath(gc, path, cW, cH); + gc.restore(); + } + + private void drawCavePath(GraphicsContext gc, float[][] path, double cW, double cH) { + if (path.length < 2) return; + double bx0 = Double.MAX_VALUE, bx1 = -Double.MAX_VALUE; + double by0 = Double.MAX_VALUE, by1 = -Double.MAX_VALUE; + for (float[] pt : path) { + double px = toCanvasX(pt[0]), py = toCanvasZ(pt[1]); + if (px < bx0) bx0 = px; if (px > bx1) bx1 = px; + if (py < by0) by0 = py; if (py > by1) by1 = py; + } + if (bx1 < -8 || bx0 > cW + 8 || by1 < -8 || by0 > cH + 8) return; + gc.beginPath(); + gc.moveTo(toCanvasX(path[0][0]), toCanvasZ(path[0][1])); + for (int i = 1; i < path.length; i++) gc.lineTo(toCanvasX(path[i][0]), toCanvasZ(path[i][1])); + gc.closePath(); + gc.stroke(); + } + private void drawTunnelEntrancesOverlay(GraphicsContext gc) { if (tunnelEntrances.isEmpty()) return; gc.save(); 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 e2b400d..9e5011d 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 @@ -23,7 +23,6 @@ import javafx.scene.image.WritableImage; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; import javafx.scene.paint.Color; -import javafx.scene.shape.ArcType; import javafx.scene.shape.StrokeLineCap; import javafx.scene.shape.StrokeLineJoin; import javafx.scene.text.Font; @@ -128,8 +127,8 @@ public class WorldMapView extends VBox { private List buildingFootprints = new ArrayList<>(); private List cachedPathChains = new ArrayList<>(); - /** Tunneleingang-Daten: [worldX, worldZ, dirX, dirZ, radiusWorld] */ - private List tunnelEntrances = new ArrayList<>(); + private List tunnelPaths = new ArrayList<>(); + private WritableImage bakedTunnelOverlay = null; private static final java.util.concurrent.ConcurrentHashMap MODEL_BOUNDS_CACHE = new java.util.concurrent.ConcurrentHashMap<>(); private static final float[] DEFAULT_EXTENTS = {5f, 5f}; @@ -380,9 +379,7 @@ public class WorldMapView extends VBox { List waters = WaterBodyIO.load(); List models = PlacedModelIO.load(); List waterfalls = WaterfallIO.load(); - VoxelData vd = bakeVoxelData(mapData); - float[] voxelSurf = vd.voxelSurface(); - boolean[] sandwichMask = vd.sandwichMask(); + float[] voxelSurf = bakeVoxelHeights(mapData); Platform.runLater(() -> statusLbl.setText("Berechne Modell…")); WorldMapRenderModel model = buildRenderModel( @@ -406,15 +403,17 @@ public class WorldMapView extends VBox { List allBuildingFootprints = computeBuildingFootprints(model, findAssetRoot()); List allPathChains = loadPathChains(); - List newTunnelEntrances = computeTunnelEntrances(model.mapData()); Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); boolean[] caveMask = loadCaveMask(model.mapData()); - List[] splitChains = splitPathChains(allPathChains, model.voxelSurface(), sandwichMask); + List[] splitChains = splitPathChains(allPathChains, model.voxelSurface(), caveMask); List newPathChains = splitChains[0]; // nur Oberfläche List newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false); List surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false); + List newTunnelPaths = buildTunnelContourPaths(caveMask); + int[] tunnelFillPx = buildTunnelFillPixels(caveMask); + // Hires-Chunks im Hintergrund generieren (nach dem UI-Update, nicht-blockierend) final WorldMapRenderModel chunkModel = model; Platform.runLater(() -> statusLbl.setText("Generiere Hires-Chunks…")); @@ -436,7 +435,8 @@ public class WorldMapView extends VBox { forestPaths = newForestPaths; buildingFootprints = newBuildingFootprints; cachedPathChains = newPathChains; - tunnelEntrances = newTunnelEntrances; + tunnelPaths = newTunnelPaths; + bakedTunnelOverlay = toWritableImage(tunnelFillPx, RENDER_SIZE); fitToView(); redraw(); progress.setVisible(false); @@ -513,8 +513,8 @@ public class WorldMapView extends VBox { } // Layer 2: Klippen (Grau-Füllung + Schraffierung + Vektor-Kontur) if (cbCliffs.isSelected()) drawCliffOverlay(gc); - // Layer 2.5: Tunneleingänge (schwarze Halbkreise) - if (cbTunnels.isSelected()) drawTunnelEntrancesOverlay(gc); + // Layer 2.5: Tunnel-Fläche (gefülltes Polygon + Kontur) + if (cbTunnels.isSelected()) drawTunnelOverlay(gc); // Layer 3: Wald (Füllung + Vektor-Kontur) – unter Wasser if (cbForest.isSelected()) drawForestOverlay(gc); // Layer 3.5: Gebäude (Füllung + Vektor-Kontur) – unter Wasser @@ -1046,32 +1046,50 @@ public class WorldMapView extends VBox { result.add(new float[]{wx, wz, dirX, dirZ, radius}); } - private void drawTunnelEntrancesOverlay(GraphicsContext gc) { - if (tunnelEntrances.isEmpty()) return; - gc.save(); - gc.setFill(Color.BLACK); - gc.setStroke(Color.BLACK); - gc.setLineWidth(1.0); - for (float[] te : tunnelEntrances) { - double cx = toCanvasX(te[0]); - double cz = toCanvasZ(te[1]); - double dirX = te[2]; // toward solid (in map X = canvas X) - double dirZ = te[3]; // toward solid (in map Z = canvas Y) - double radius = te[4] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE - 1) * scale; - radius = Math.max(4.0, Math.min(radius, 20.0)); - - // Rotate so the D-curve points toward the solid (into the cliff) - double angleDeg = Math.toDegrees(Math.atan2(dirZ, dirX)); - + private void drawTunnelOverlay(GraphicsContext gc) { + double cW = canvas.getWidth(), cH = canvas.getHeight(); + if (bakedTunnelOverlay != null) { gc.save(); - gc.translate(cx, cz); - gc.rotate(angleDeg); - // Semicircle: curve on the +X side (toward solid after rotation), flat face on -X - // fillArc(x, y, w, h, startAngle, extent): startAngle=−90° (screen-down), CCW 180° → goes through east (right) - gc.fillArc(-radius, -radius, radius * 2, radius * 2, -90, 180, ArcType.CHORD); + gc.setImageSmoothing(false); + gc.drawImage(bakedTunnelOverlay, -panX / scale, -panY / scale, cW / scale, cH / scale, 0, 0, cW, cH); gc.restore(); } - gc.restore(); + if (!tunnelPaths.isEmpty()) { + gc.save(); + gc.setStroke(Color.rgb(40, 22, 5, 0.90)); + gc.setLineWidth(overlayLineWidth() + 0.5); + gc.setLineDashes(); + gc.setLineCap(StrokeLineCap.ROUND); + gc.setLineJoin(StrokeLineJoin.ROUND); + for (float[][] path : tunnelPaths) drawCoastPath(gc, path, cW, cH, 8); + gc.restore(); + } + } + + static List buildTunnelContourPaths(boolean[] caveMask) { + final int SIZE = CAVE_MASK_SIZE; + float[] h = new float[SIZE * SIZE]; + for (int i = 0; i < caveMask.length; i++) h[i] = caveMask[i] ? -1f : 1f; + List segs = new ArrayList<>(); + marchingSquares(h, SIZE, 0f, null, null, segs); + List paths = assembleClosedPaths(segs.toArray(new float[0][]), 4); + final float MIN_PERI_SQ = 20f * 20f; + return paths.stream().filter(p -> pathPerimeterSq(p) >= MIN_PERI_SQ).collect(Collectors.toList()); + } + + private static int[] buildTunnelFillPixels(boolean[] caveMask) { + final int SIZE = CAVE_MASK_SIZE; + final int OUT = RENDER_SIZE; + final int FILL = 0x60_3D2208; // 37% alpha, very dark earthy brown + int[] px = new int[OUT * OUT]; + for (int py = 0; py < OUT; py++) { + int mz = py * SIZE / OUT; + for (int bx = 0; bx < OUT; bx++) { + int mx = bx * SIZE / OUT; + if (caveMask[mz * SIZE + mx]) px[py * OUT + bx] = FILL; + } + } + return px; } private static List computeBuildingFootprints(WorldMapRenderModel model, Path assetRoot) { @@ -1115,17 +1133,17 @@ public class WorldMapView extends VBox { /** * 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. + * Nutzt die Cave-Chunk-Maske als räumlichen Filter (Chunk-Level-Erkennung) + * kombiniert mit Y-Koordinaten-Prüfung gegen die Voxel-Oberfläche. * Rückgabe: Index 0 = Oberflächensegmente, Index 1 = Tunnel-Segmente. */ @SuppressWarnings("unchecked") static List[] splitPathChains(List chains, float[] voxelSurface, - boolean[] sandwichMask) { + boolean[] caveMask) { List surface = new ArrayList<>(); List cave = new ArrayList<>(); for (float[][] chain : chains) { - splitChainByVoxelSurface(chain, voxelSurface, sandwichMask, surface, cave); + splitChainByVoxelSurface(chain, voxelSurface, caveMask, surface, cave); } return new List[]{surface, cave}; } @@ -1134,43 +1152,43 @@ public class WorldMapView extends VBox { /** * 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ß. + * Bedingung: Position liegt in einem Tunnel-Chunk-Bereich (Cave-Maske) + * UND das höchste Solid in dieser Spalte ist ≥ PATH_UNDERGROUND_CLEARANCE über der Knoten-Y. */ - private static boolean isNodeUnderground(float[] node, float[] voxelSurface, boolean[] sandwichMask) { + private static boolean isNodeUnderground(float[] node, float[] voxelSurface, boolean[] caveMask) { if (node.length < 3) return false; float wy = node[2]; if (Float.isNaN(wy)) return false; + // Cave-Maske (Chunk-Level): nur Bereiche nahe Tunneln prüfen + if (caveMask != null && !isCavePoint(caveMask, node[0], node[1])) return false; + // Y-Prüfung: Knoten muss unter festem Voxel-Material liegen int VS = WorldMapRenderer.VOXEL_SURFACE_RES; float WH = WorldMapRenderer.WORLD_HALF, WS = WorldMapRenderer.WORLD_SIZE; int px = Math.round((node[0] + WH) / WS * (VS - 1)); 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, + boolean[] caveMask, List surface, List cave) { if (chain.length < 2) return; List current = new ArrayList<>(); - boolean currentCave = isNodeUnderground(chain[0], voxelSurface, sandwichMask); + boolean currentCave = isNodeUnderground(chain[0], voxelSurface, caveMask); 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, sandwichMask); + boolean bCave = isNodeUnderground(b, voxelSurface, caveMask); if (currentCave == bCave) { current.add(b); } else { - float[] crossing = findPathCrossing(a, b, voxelSurface, sandwichMask, currentCave); + float[] crossing = findPathCrossing(a, b, voxelSurface, caveMask, currentCave); current.add(crossing); if (current.size() >= 2) { (currentCave ? cave : surface).add(current.toArray(new float[0][])); @@ -1187,15 +1205,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[] sm, + private static float[] findPathCrossing(float[] a, float[] b, float[] vs, boolean[] caveMask, 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, sm) == aIsUnderground) { ax = mx; az = mz; ay = my; } - else { bx = mx; bz = mz; by = my; } + if (isNodeUnderground(mid, vs, caveMask) == 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}; } @@ -1389,26 +1407,17 @@ public class WorldMapView extends VBox { return i; } - private record VoxelData(float[] voxelSurface, boolean[] sandwichMask) {} - - private VoxelData bakeVoxelData(MapData mapData) { + /** Berechnet voxelSurface (höchstes Solid-Voxel pro Spalte) aus Live- oder Disk-Chunks. */ + private float[] bakeVoxelHeights(MapData mapData) { int VS = WorldMapRenderer.VOXEL_SURFACE_RES; - // Bevorzuge Live-Chunks aus dem Editor-Speicher, falle auf Disk zurück List chunks = voxelChunkSupplier != null ? voxelChunkSupplier.get() : 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 new VoxelData(empty, sandwichMask); - } - // Kern-Logik ist dieselbe wie WorldMapRenderer.bakeVoxelSurface, aber mit - // Live-Chunks statt VoxelChunkIO.loadAll(). float[] voxelSurface = new float[VS * VS]; java.util.Arrays.fill(voxelSurface, Float.NaN); + if (chunks.isEmpty()) { return voxelSurface; } float WH = WorldMapRenderer.WORLD_HALF; float WS = WorldMapRenderer.WORLD_SIZE; int UV = MapData.UPPER_VERTS; @@ -1441,57 +1450,7 @@ public class WorldMapView extends VBox { } } } - // 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; + return voxelSurface; } private WorldMapRenderModel buildRenderModel(