Karte: Y-basierter Pfad-Split, Label/Export in Höhlenkarte, Sync-Fix

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:32:28 +02:00
parent 174579cd0b
commit 8430eb41d6
3 changed files with 165 additions and 47 deletions

View File

@@ -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(

View File

@@ -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<Stage> 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<float[]> tunnelEntrances = new ArrayList<>();
private List<PlacedArea> cachedAreas = new ArrayList<>();
private List<float[]> 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<Stage> 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<float[]> entrances = WorldMapView.computeTunnelEntrances(mapData);
List<float[]> allFootprints = WorldMapView.computeBuildingFootprintsSimple(models);
boolean[] caveMask = WorldMapView.loadCaveMask(mapData);
List<float[][]>[] splitChains = WorldMapView.splitPathChains(allPaths, caveMask);
List<float[][]> cavePaths = splitChains[1]; // nur Tunnel-Segmente
List<float[]> caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true);
List<PlacedArea> 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<float[][]>[] splitChains = WorldMapView.splitPathChains(allPaths, voxelSurface);
List<float[][]> cavePaths = splitChains[1];
// Gebäude + Areas: Cave-Maske (keine Y-Info)
List<float[]> caveFootprints = WorldMapView.filterBuildingsByCave(allFootprints, caveMask, true);
List<PlacedArea> 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));

View File

@@ -404,8 +404,8 @@ public class WorldMapView extends VBox {
List<float[]> newTunnelEntrances = computeTunnelEntrances(model.mapData());
Platform.runLater(() -> statusLbl.setText("Berechne Cave-Maske…"));
boolean[] caveMask = loadCaveMask(model.mapData());
List<float[][]>[] splitChains = splitPathChains(allPathChains, caveMask);
boolean[] caveMask = loadCaveMask(model.mapData());
List<float[][]>[] splitChains = splitPathChains(allPathChains, model.voxelSurface());
List<float[][]> newPathChains = splitChains[0]; // nur Oberfläche
List<float[]> newBuildingFootprints = filterBuildingsByCave(allBuildingFootprints, caveMask, false);
List<PlacedArea> surfaceAreas = filterAreasByCave(new ArrayList<>(model.areas()), caveMask, false);
@@ -837,14 +837,14 @@ public class WorldMapView extends VBox {
List<float[]> 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<float[][]>[] splitPathChains(List<float[][]> chains, boolean[] caveMask) {
static List<float[][]>[] splitPathChains(List<float[][]> chains, float[] voxelSurface) {
List<float[][]> surface = new ArrayList<>();
List<float[][]> 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<float[][]> surface, List<float[][]> 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<float[][]> surface, List<float[][]> cave) {
if (chain.length < 2) return;
List<float[]> current = new ArrayList<>();
boolean currentCave = isCavePoint(caveMask, chain[0][0], chain[0][1]);
List<float[]> 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;