From 8430eb41d69c6e626daf2e9379b3bd558625cb59 Mon Sep 17 00:00:00 2001 From: Mario Date: Sun, 6 Sep 2026 22:32:28 +0200 Subject: [PATCH] =?UTF-8?q?Karte:=20Y-basierter=20Pfad-Split,=20Label/Expo?= =?UTF-8?q?rt=20in=20H=C3=B6hlenkarte,=20Sync-Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pfad-Trennung (Oberfläche vs. Untergrund): - loadPathChains() speichert jetzt Y-Koordinate an Index 2 - splitPathChains() prüft voxelSurface(x,z) > node_y + 3m statt 2D-Cave-Maske; erkennt damit alle Tunnel-Typen korrekt - Bisektion interpoliert Y für präzisen Grenzpunkt Höhlenkarte Features: - Label-Toggle (Bearbeiten-Modus für Area-Labels) + Klick-Handler - PNG-Export (⇩) rendert 4096×4096 via buildCaveMap() - mapData wird gecacht für späteren Export ohne Reload Zoom/Position-Sync: - Tab-Listener liest Werte vor dem Wechsel, übergibt sie per Platform.runLater() NACH JavaFX-Layout → kein Sprung mehr Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/de/blight/editor/EditorApp.java | 24 ++-- .../java/de/blight/editor/ui/CaveMapView.java | 124 +++++++++++++++--- .../de/blight/editor/ui/WorldMapView.java | 64 ++++++--- 3 files changed, 165 insertions(+), 47 deletions(-) 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 f8d605b..3513aa4 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -5702,13 +5702,15 @@ public class EditorApp extends Application { weltkartTab = new Tab("Weltkarte", worldMapView); weltkartTab.setClosable(false); weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> { - if (isSelected && !worldMapView.isLoaded()) { + if (!isSelected) return; + if (!worldMapView.isLoaded()) { worldMapView.loadAndRender(); - } else if (isSelected) { + } else if (caveMapView != null && caveMapView.isLoaded()) { + // Sync nach Layout-Pass damit Canvas-Größe korrekt ist + final double s = caveMapView.getScale(), px = caveMapView.getPanX(), py = caveMapView.getPanY(); + javafx.application.Platform.runLater(() -> worldMapView.setView(s, px, py)); + } else { worldMapView.ensureFitToView(); - if (caveMapView != null && caveMapView.isLoaded()) { - worldMapView.setView(caveMapView.getScale(), caveMapView.getPanX(), caveMapView.getPanY()); - } } }); @@ -5716,13 +5718,15 @@ public class EditorApp extends Application { caveMapTab = new Tab("Höhlenkarte", caveMapView); caveMapTab.setClosable(false); caveMapTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> { - if (isSelected && !caveMapView.isLoaded()) { + if (!isSelected) return; + if (!caveMapView.isLoaded()) { caveMapView.loadAndRender(); - } else if (isSelected) { + } else if (worldMapView.isMapReady()) { + // Sync nach Layout-Pass damit Canvas-Größe korrekt ist + final double s = worldMapView.getScale(), px = worldMapView.getPanX(), py = worldMapView.getPanY(); + javafx.application.Platform.runLater(() -> caveMapView.setView(s, px, py)); + } else { caveMapView.ensureFitToView(); - if (worldMapView.isMapReady()) { - caveMapView.setView(worldMapView.getScale(), worldMapView.getPanX(), worldMapView.getPanY()); - } } }); caveMapView.setFullscreenCallbacks( 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 925c96b..1eccfe7 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 @@ -6,6 +6,7 @@ import de.blight.common.map.WorldMapRenderer; import javafx.application.Platform; import javafx.embed.swing.SwingFXUtils; import javafx.geometry.Insets; +import javafx.scene.Cursor; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.*; @@ -15,9 +16,13 @@ import javafx.scene.paint.Color; import javafx.scene.shape.ArcType; import javafx.scene.shape.StrokeLineCap; import javafx.scene.shape.StrokeLineJoin; +import javafx.stage.FileChooser; import javafx.stage.Stage; +import javax.imageio.ImageIO; import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -42,15 +47,18 @@ public class CaveMapView extends VBox { private final StackPane canvasPane = new StackPane(); private final AtomicBoolean loading = new AtomicBoolean(false); + private final Supplier stageSupplier; + // Layer-Checkboxen private final CheckBox cbEntrances = layerCheck("Eingänge", true); private final CheckBox cbBuildings = layerCheck("Gebäude", true); private final CheckBox cbPaths = layerCheck("Wege", true); private final CheckBox cbAreas = layerCheck("Areas", true); - private final Button fullscreenBtn = new Button(">>"); - private final Button backBtn = new Button("<<"); - private boolean isFullscreen = false; + private final ToggleButton labelBtn = new ToggleButton("Label"); + private final Button fullscreenBtn = new Button(">>"); + private final Button backBtn = new Button("<<"); + private boolean isFullscreen = false; private double savedScale, savedPanX, savedPanY; private Runnable enterFullscreenCallback = null; private Runnable exitFullscreenCallback = null; @@ -60,6 +68,7 @@ public class CaveMapView extends VBox { private List tunnelEntrances = 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; @@ -67,6 +76,8 @@ public class CaveMapView extends VBox { private boolean autoLoaded = false; public CaveMapView(Supplier stageSupplier) { + this.stageSupplier = stageSupplier; + cbEntrances.selectedProperty().addListener((obs, o, n) -> redraw()); cbBuildings.selectedProperty().addListener((obs, o, n) -> redraw()); cbPaths.selectedProperty().addListener((obs, o, n) -> redraw()); @@ -83,6 +94,14 @@ public class CaveMapView extends VBox { refreshBtn.setTooltip(new Tooltip("Aktualisieren")); refreshBtn.setOnAction(e -> loadAndRender()); + Button exportBtn = new Button("⇩"); + exportBtn.setTooltip(new Tooltip("Als PNG exportieren…")); + exportBtn.setOnAction(e -> exportPng()); + + labelBtn.setStyle("-fx-font-size: 11;"); + labelBtn.selectedProperty().addListener((obs, o, n) -> + canvas.setCursor(n ? Cursor.CROSSHAIR : Cursor.DEFAULT)); + fullscreenBtn.setTooltip(new Tooltip("Maximieren")); backBtn.setTooltip(new Tooltip("Minimieren")); fullscreenBtn.setOnAction(e -> enterFullscreen()); @@ -92,7 +111,11 @@ public class CaveMapView extends VBox { ToolBar bar = new ToolBar( layerMenu, new Separator(), + new Label("Bearbeiten:"), + labelBtn, + new Separator(), refreshBtn, + exportBtn, new Separator(), fullscreenBtn, backBtn @@ -134,7 +157,6 @@ public class CaveMapView extends VBox { public double getPanX() { return panX; } public double getPanY() { return panY; } - /** Setzt Zoom und Position direkt (für Tab-Wechsel-Sync). */ public void setView(double newScale, double newPanX, double newPanY) { scale = newScale; panX = newPanX; @@ -171,6 +193,12 @@ public class CaveMapView extends VBox { panY = ds[3] + (e.getY()-ds[1]); redraw(); }); + canvas.setOnMouseReleased(e -> { + boolean wasDrag = Math.abs(e.getX() - ds[0]) >= 5 || Math.abs(e.getY() - ds[1]) >= 5; + if (!wasDrag && labelBtn.isSelected()) { + handleLabelPlacement(e.getX(), e.getY()); + } + }); canvas.setOnMouseMoved(e -> { if (mapFxImage == null) return; double wx = (e.getX() - panX) / scale / RENDER_SIZE * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF; @@ -179,6 +207,60 @@ public class CaveMapView extends VBox { }); } + private void handleLabelPlacement(double canvasX, double canvasY) { + if (cachedAreas.isEmpty()) return; + float wx = toWorldX(canvasX); + float wz = toWorldZ(canvasY); + PlacedArea best = null; + int bestIdx = -1; + double bestSize = Double.MAX_VALUE; + for (int i = 0; i < cachedAreas.size(); i++) { + PlacedArea a = cachedAreas.get(i); + if (WorldMapView.polyContainsStatic(a.pointsX(), a.pointsZ(), wx, wz)) { + double sz = WorldMapView.boundingBoxAreaStatic(a.pointsX(), a.pointsZ()); + if (sz < bestSize) { bestSize = sz; best = a; bestIdx = i; } + } + } + if (best != null) { + cachedAreas.set(bestIdx, new PlacedArea(best.pointsX(), best.pointsZ(), + best.areaId(), best.triggers(), wx, wz)); + try { AreaIO.save(cachedAreas); } catch (IOException ex) { statusLbl.setText("Fehler: " + ex.getMessage()); return; } + redraw(); + statusLbl.setText(String.format("Label %s → (%.0f, %.0f)", best.areaId(), wx, wz)); + } + } + + private float toWorldX(double canvasX) { + return (float)((canvasX - panX) / scale / (RENDER_SIZE - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + } + private float toWorldZ(double canvasZ) { + return (float)((canvasZ - panY) / scale / (RENDER_SIZE - 1) * WorldMapRenderer.WORLD_SIZE - WorldMapRenderer.WORLD_HALF); + } + + private void exportPng() { + FileChooser fc = new FileChooser(); + fc.setTitle("Höhlenkarte als PNG exportieren"); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG-Bild", "*.png")); + fc.setInitialFileName("hoehlenkarte.png"); + File file = fc.showSaveDialog(stageSupplier.get()); + if (file == null) return; + if (cachedMapData == null) { statusLbl.setText("Karte noch nicht geladen."); return; } + statusLbl.setText("Exportiere 4096×4096 PNG…"); + progress.setVisible(true); + MapData md = cachedMapData; + Thread t = new Thread(() -> { + try { + BufferedImage bi = WorldMapRenderer.buildCaveMap(md, 4096); + ImageIO.write(bi, "PNG", file); + Platform.runLater(() -> { progress.setVisible(false); statusLbl.setText("Exportiert: " + file.getName()); }); + } catch (IOException ex) { + Platform.runLater(() -> { progress.setVisible(false); statusLbl.setText("Export-Fehler: " + ex.getMessage()); }); + } + }, "CaveMapPngExport"); + t.setDaemon(true); + t.start(); + } + public boolean isLoaded() { return mapFxImage != null; } public void ensureFitToView() { if (mapFxImage != null) fitToView(); } @@ -207,11 +289,18 @@ public class CaveMapView extends VBox { List entrances = WorldMapView.computeTunnelEntrances(mapData); List allFootprints = WorldMapView.computeBuildingFootprintsSimple(models); - boolean[] caveMask = WorldMapView.loadCaveMask(mapData); - List[] splitChains = WorldMapView.splitPathChains(allPaths, caveMask); - List cavePaths = splitChains[1]; // nur Tunnel-Segmente - List caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true); - List caveAreas = WorldMapView.filterAreasByCave(new ArrayList<>(areas), caveMask, true); + Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); + boolean[] caveMask = WorldMapView.loadCaveMask(mapData); + + // Pfade: Y-basierte Trennung (voxelSurface > path_y + clearance) + Platform.runLater(() -> statusLbl.setText("Berechne VoxelSurface…")); + float[] voxelSurface = WorldMapRenderer.bakeVoxelSurface(mapData); + List[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface); + List cavePaths = splitChains[1]; + + // Gebäude + Areas: Cave-Maske (keine Y-Info) + List caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true); + List caveAreas = WorldMapView.filterAreasByCave(new ArrayList<>(areas), caveMask, true); Platform.runLater(() -> { mapFxImage = SwingFXUtils.toFXImage(bi, null); @@ -219,6 +308,7 @@ public class CaveMapView extends VBox { tunnelEntrances = entrances; cachedAreas = caveAreas; buildingFootprints = caveFootprints; + cachedMapData = mapData; fitToView(); redraw(); progress.setVisible(false); @@ -298,6 +388,13 @@ public class CaveMapView extends VBox { if (cbAreas.isSelected()) drawAreasOverlay(gc); } + private double toCanvasX(float wx) { + return panX + (wx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + } + private double toCanvasZ(float wz) { + return panY + (wz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; + } + private double overlayLineWidth() { return switch (zoomLevelIdx) { case 3 -> 2.5; @@ -314,17 +411,12 @@ public class CaveMapView extends VBox { }; } - private double toCanvasX(float wx) { - return panX + (wx + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; - } - private double toCanvasZ(float wz) { - return panY + (wz + WorldMapRenderer.WORLD_HALF) / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale; - } - 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 r = Math.max(4.0, Math.min( te[4] / WorldMapRenderer.WORLD_SIZE * (RENDER_SIZE-1) * scale, 20.0)); 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 9c3573d..92abb2b 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 @@ -404,8 +404,8 @@ public class WorldMapView extends VBox { List newTunnelEntrances = computeTunnelEntrances(model.mapData()); Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…")); - boolean[] caveMask = loadCaveMask(model.mapData()); - List[] splitChains = splitPathChains(allPathChains, caveMask); + boolean[] caveMask = loadCaveMask(model.mapData()); + List[] splitChains = splitPathChains(allPathChains, model.voxelSurface()); List newPathChains = splitChains[0]; // nur Oberfläche List newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false); List surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false); @@ -837,14 +837,14 @@ public class WorldMapView extends VBox { List chain = new ArrayList<>(); de.blight.common.path.PathNode startNode = nodeMap.get(jUuid); - chain.add(new float[]{startNode.getPosition().x, startNode.getPosition().z}); + chain.add(new float[]{startNode.getPosition().x, startNode.getPosition().z, startNode.getPosition().y}); String prev = jUuid, curr = nb; while (curr != null) { visitedEdges.add(pathEdgeKey(prev, curr)); de.blight.common.path.PathNode currNode = nodeMap.get(curr); if (currNode == null) break; - chain.add(new float[]{currNode.getPosition().x, currNode.getPosition().z}); + chain.add(new float[]{currNode.getPosition().x, currNode.getPosition().z, currNode.getPosition().y}); if (junctions.contains(curr)) break; String next = null; for (String n2 : adj.getOrDefault(curr, java.util.Collections.emptyList())) { @@ -1109,36 +1109,53 @@ public class WorldMapView extends VBox { } /** - * Teilt Pfad-Ketten an Tunnel-Grenzen auf. + * 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). * Rückgabe: Index 0 = Oberflächensegmente, Index 1 = Tunnel-Segmente. */ @SuppressWarnings("unchecked") - static List[] splitPathChains(List chains, boolean[] caveMask) { + static List[] splitPathChains(List chains, float[] voxelSurface) { List surface = new ArrayList<>(); List cave = new ArrayList<>(); for (float[][] chain : chains) { - splitChainByCaveMask(chain, caveMask, surface, cave); + splitChainByVoxelSurface(chain, voxelSurface, surface, cave); } return new List[]{surface, cave}; } - private static void splitChainByCaveMask(float[][] chain, boolean[] caveMask, - List surface, List cave) { + private static final float PATH_UNDERGROUND_CLEARANCE = 3.0f; + + private static boolean isNodeUnderground(float[] node, float[] voxelSurface) { + if (node.length < 3) return false; + float wy = node[2]; + if (Float.isNaN(wy)) return false; + 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)); + float vs = voxelSurface[pz * VS + px]; + return !Float.isNaN(vs) && vs > wy + PATH_UNDERGROUND_CLEARANCE; + } + + private static void splitChainByVoxelSurface(float[][] chain, float[] voxelSurface, + List surface, List cave) { if (chain.length < 2) return; - List current = new ArrayList<>(); - boolean currentCave = isCavePoint(caveMask, chain[0][0], chain[0][1]); + List current = new ArrayList<>(); + boolean currentCave = isNodeUnderground(chain[0], voxelSurface); current.add(chain[0]); for (int i = 0; i < chain.length - 1; i++) { float[] a = chain[i]; float[] b = chain[i + 1]; - boolean bCave = isCavePoint(caveMask, b[0], b[1]); + boolean bCave = isNodeUnderground(b, voxelSurface); if (currentCave == bCave) { current.add(b); } else { - // Genauen Grenzpunkt per Bisektion ermitteln - float[] crossing = findCaveCrossing(a, b, caveMask, currentCave); + float[] crossing = findPathCrossing(a, b, voxelSurface, currentCave); current.add(crossing); if (current.size() >= 2) { (currentCave ? cave : surface).add(current.toArray(new float[0][])); @@ -1154,15 +1171,17 @@ public class WorldMapView extends VBox { } } - /** Bisektion: findet den Weltpunkt, an dem die Cave-Maske umspringt (12 Iterationen ≈ 0.05m Genauigkeit). */ - private static float[] findCaveCrossing(float[] a, float[] b, boolean[] mask, boolean aIsCave) { - float ax = a[0], az = a[1], bx = b[0], bz = b[1]; + /** Bisektion mit Y-Interpolation: 12 Iterationen ≈ 0.05m Genauigkeit. */ + private static float[] findPathCrossing(float[] a, float[] b, float[] vs, 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; - if (isCavePoint(mask, mx, mz) == aIsCave) { ax = mx; az = mz; } - else { bx = mx; bz = mz; } + 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; } } - return new float[]{(ax + bx) * 0.5f, (az + bz) * 0.5f}; + return new float[]{(ax + bx) * 0.5f, (az + bz) * 0.5f, (ay + by) * 0.5f}; } /** Filtert Gebäude-Footprints: wantCave=true → nur Untergrund, false → nur Oberfläche. */ @@ -1774,6 +1793,9 @@ public class WorldMapView extends VBox { } } + static boolean polyContainsStatic(float[] xs, float[] zs, float px, float pz) { return polyContains(xs, zs, px, pz); } + static double boundingBoxAreaStatic(float[] xs, float[] zs) { return boundingBoxArea(xs, zs); } + private static boolean polyContains(float[] xs, float[] zs, float px, float pz) { boolean inside = false; int n = xs.length;