Weltkarte: Render-Modell-Trennung, UI-Umbau und Kamera-Indikator

- WorldMapRenderModel: neues Record als Zwischenschicht zwischen I/O und Rendering
- WorldMapRenderer: render() akzeptiert Modell direkt; rückwärtskompatible Wrapper-Signatur für MinimapState
- WorldMapView: Layer-Auswahl als MenuButton mit Checkboxen; Vollbild-Modus (centerStack-Swap); Kamera-Indikator mit FOV-Kegel (120ms-Timer, unabhängig vom Karten-Rendering); Linksklick teleportiert JME3-Kamera unter Beibehaltung der Terrain-Höhendifferenz
- EditorApp: CameraInfoSupplier, TeleportCallback und FullscreenCallbacks verdrahtet
- MinimapState, Logback: Debug-Logging für Modell-Build und Render-Zyklen
- Map-Daten: Zwischenstand Weltdaten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 13:24:35 +02:00
parent 68ec75c41a
commit 36c6a9b7d8
23 changed files with 806 additions and 189 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,27 @@
package de.blight.common.map;
import de.blight.common.*;
import de.blight.common.model.Location;
import java.util.List;
/**
* Fertig vorberechnetes Karten-Modell: enthält alle Rohdaten + die im Editor
* vorcompilierten Overlay-Informationen (SeaMask, Küstenpfade, Baum-Cluster).
* WorldMapRenderer.render() arbeitet nur noch gegen dieses Modell.
*/
public record WorldMapRenderModel(
// Terrain-Daten + Polygon-Objekte (werden vom Renderer für das PNG genutzt)
MapData mapData,
List<PlacedArea> areas,
List<PlacedLocationZone> zones,
List<Location> locations,
List<PlacedWater> waters,
List<PlacedModel> models,
int[] slotColorsRGB,
// Vorberechnete Overlay-Daten (werden vom Canvas genutzt, nicht vom PNG-Renderer)
boolean[] seaMask, // Wasser-Pixel-Maske bei SEA_MASK_SIZE-Auflösung
float[] terrainSamples, // Höhenwerte bei SEA_MASK_SIZE (für Wellen-Prüfung)
List<float[][]> coastPaths, // geglättete Marching-Squares Küstenpfade (Weltkoord.)
List<List<PlacedModel>> treeClusters
) {}

View File

@@ -62,17 +62,25 @@ public final class WorldMapRenderer {
public static boolean[] buildSeaMask(MapData m, int size) {
int TV = MapData.TERRAIN_VERTS;
int UV = MapData.UPPER_VERTS;
boolean[] mask = new boolean[size * size];
for (int py = 0; py < size; py++) {
for (int px = 0; px < size; px++) {
int hx = Math.min((int)((float) px / (size - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (size - 1) * (TV - 1)), TV - 1);
mask[py * size + px] = m.terrainHeight[hz * TV + hx] < 0f;
int ux = Math.min((int)((float) px / (size - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (size - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
mask[py * size + px] = h < 0f;
}
}
return mask;
}
private static final int WATER_COLOR = 0xFF_ADD8E6;
// Default-Slot-Farben für Slots 1-8 (Base-Layer 1-4 + Upper-Layer 5-8)
private static final int[] DEF_SLOT_R = { 71, 115, 140, 204, 90, 130, 110, 180 };
private static final int[] DEF_SLOT_G = { 148, 82, 115, 184, 80, 90, 60, 100 };
@@ -80,15 +88,17 @@ public final class WorldMapRenderer {
private WorldMapRenderer() {}
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
MapData m = input.mapData();
/** Hauptmethode: rendert das Hintergrund-PNG aus einem vorberechneten Modell. */
public static BufferedImage render(WorldMapRenderModel model, int targetSize, RenderOptions opts) {
MapData m = model.mapData();
int TV = MapData.TERRAIN_VERTS;
int SS = MapData.SPLAT_SIZE;
int[] slotR = slotChannel(input, 0);
int[] slotG = slotChannel(input, 1);
int[] slotB = slotChannel(input, 2);
int[] slotR = slotChannel(model.slotColorsRGB(), 0);
int[] slotG = slotChannel(model.slotColorsRGB(), 1);
int[] slotB = slotChannel(model.slotColorsRGB(), 2);
// ── 1. Heightmap auf Zielauflösung samplen ────────────────────────────
int UV = MapData.UPPER_VERTS;
float[] heights = new float[targetSize * targetSize];
float minH = Float.MAX_VALUE, maxH = -Float.MAX_VALUE;
@@ -96,7 +106,11 @@ public final class WorldMapRenderer {
for (int px = 0; px < targetSize; px++) {
int hx = Math.min((int)((float) px / (targetSize - 1) * (TV - 1)), TV - 1);
int hz = Math.min((int)((float) py / (targetSize - 1) * (TV - 1)), TV - 1);
int ux = Math.min((int)((float) px / (targetSize - 1) * (UV - 1)), UV - 1);
int uz = Math.min((int)((float) py / (targetSize - 1) * (UV - 1)), UV - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) minH = h;
if (h > maxH) maxH = h;
@@ -161,6 +175,9 @@ public final class WorldMapRenderer {
}
}
// Kuwahara-Filter: lässt das Terrain wie gemalt wirken
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── 3. Vektor-Overlays ────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -171,10 +188,10 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -186,7 +203,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -219,7 +236,7 @@ public final class WorldMapRenderer {
}
}
// Wasserflächen: nur wenn groß genug
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
Polygon poly = new Polygon(xs, ys, xs.length);
@@ -250,7 +267,7 @@ public final class WorldMapRenderer {
gfx.setColor(Color.BLACK);
gfx.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Wasserflächen: Polygon-Umriss
for (PlacedWater w : input.waters()) {
for (PlacedWater w : model.waters()) {
int[] xs = worldToPixels(w.pointsX(), targetSize);
int[] ys = worldToPixels(w.pointsZ(), targetSize);
gfx.drawPolygon(xs, ys, xs.length);
@@ -293,7 +310,7 @@ public final class WorldMapRenderer {
10f, new float[]{dash, gap}, 0f);
int aFontSize = Math.max(8, targetSize / 160);
gfx.setFont(new Font("SansSerif", Font.BOLD, aFontSize));
for (PlacedArea a : input.areas()) {
for (PlacedArea a : model.areas()) {
int[] xs = worldToPixels(a.pointsX(), targetSize);
int[] ys = worldToPixels(a.pointsZ(), targetSize);
gfx.setStroke(dashed);
@@ -314,7 +331,7 @@ public final class WorldMapRenderer {
// Location-Zonen
if (opts.showZones()) {
gfx.setStroke(new BasicStroke(lineW));
for (PlacedLocationZone z : input.zones()) {
for (PlacedLocationZone z : model.zones()) {
int[] xs = worldToPixels(z.pointsX(), targetSize);
int[] ys = worldToPixels(z.pointsZ(), targetSize);
gfx.setColor(new Color(240, 190, 40, 70));
@@ -324,13 +341,13 @@ public final class WorldMapRenderer {
}
}
// Modell-Punkte
if (opts.showModels()) {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
int mx = worldToPixel(model.x(), targetSize);
int mz = worldToPixel(model.z(), targetSize);
for (PlacedModel pm : model.models()) {
if (isTree(pm)) { continue; }
int mx = worldToPixel(pm.x(), targetSize);
int mz = worldToPixel(pm.z(), targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
}
}
@@ -339,7 +356,7 @@ public final class WorldMapRenderer {
if (opts.showLocations()) {
int fontSize = Math.max(8, targetSize / 140);
gfx.setFont(new Font("SansSerif", Font.BOLD, fontSize));
for (Location loc : input.locations()) {
for (Location loc : model.locations()) {
if (!loc.isShowOnMap()) continue;
if (loc.getId() == null || loc.getId().isEmpty()) continue;
float wx = Float.isNaN(loc.getLabelX()) ? loc.getCenterX() : loc.getLabelX();
@@ -354,6 +371,15 @@ public final class WorldMapRenderer {
return img;
}
/** Rückwärts-kompatibel: baut ein minimales Modell (ohne Overlay-Daten) und delegiert. */
public static BufferedImage render(RenderInput input, int targetSize, RenderOptions opts) {
return render(new WorldMapRenderModel(
input.mapData(), input.areas(), input.zones(), input.locations(),
input.waters(), input.models(), input.slotColorsRGB(),
null, null, null, null
), targetSize, opts);
}
/**
* Rendert einen rechteckigen Weltausschnitt als {@link BufferedImage}.
* Koordinatenursprung und Skalierung passen sich dem Ausschnitt an,
@@ -383,16 +409,21 @@ public final class WorldMapRenderer {
float minH = 0f, maxH = 1f;
if (opts.showTerrain() || opts.showWater()) {
int UV2 = MapData.UPPER_VERTS;
heights = new float[targetSize * targetSize];
minH = Float.MAX_VALUE;
maxH = -Float.MAX_VALUE;
for (int py = 0; py < targetSize; py++) {
float wz = wz0 + (float) py / (targetSize - 1) * rSize;
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int hz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int uz = iclamp((int) ((wz + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
for (int px = 0; px < targetSize; px++) {
float wx = wx0 + (float) px / (targetSize - 1) * rSize;
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int hx = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (TV - 1)), 0, TV - 1);
int ux = iclamp((int) ((wx + WORLD_HALF) / WORLD_SIZE * (UV2 - 1)), 0, UV2 - 1);
float h = m.terrainHeight[hz * TV + hx];
float upper = m.upperTop[uz * UV2 + ux];
if (upper > 0f && upper > h) { h = upper; }
heights[py * targetSize + px] = h;
if (h < minH) { minH = h; }
if (h > maxH) { maxH = h; }
@@ -403,9 +434,9 @@ public final class WorldMapRenderer {
// ── Terrain (optional) ────────────────────────────────────────────────
if (opts.showTerrain()) {
float heightRange = Math.max(0.01f, maxH - minH);
int[] sR = slotChannel(input, 0);
int[] sG = slotChannel(input, 1);
int[] sB = slotChannel(input, 2);
int[] sR = slotChannel(input.slotColorsRGB(), 0);
int[] sG = slotChannel(input.slotColorsRGB(), 1);
int[] sB = slotChannel(input.slotColorsRGB(), 2);
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
@@ -465,6 +496,8 @@ public final class WorldMapRenderer {
}
}
if (opts.showSplatColors()) { applyKuwahara(img, 3); }
// ── Vektor-Overlays ───────────────────────────────────────────────────
Graphics2D gfx = img.createGraphics();
gfx.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
@@ -474,7 +507,7 @@ public final class WorldMapRenderer {
// ── Weißfüllung ───────────────────────────────────────────────────────
for (int py = 0; py < targetSize; py++) {
for (int px = 0; px < targetSize; px++) {
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, 0xFFFFFFFF);
if (heights[py * targetSize + px] < 0f) img.setRGB(px, py, WATER_COLOR);
}
}
for (PlacedWater w : input.waters()) {
@@ -489,7 +522,7 @@ public final class WorldMapRenderer {
for (int py = y0; py <= y1; py++) {
for (int px = x0; px <= x1; px++) {
if (poly.contains(px, py) && heights[py * targetSize + px] < wh)
img.setRGB(px, py, 0xFFFFFFFF);
img.setRGB(px, py, WATER_COLOR);
}
}
}
@@ -629,6 +662,7 @@ public final class WorldMapRenderer {
int dotR = Math.max(1, targetSize / 600);
gfx.setColor(new Color(160, 80, 20, 200));
for (PlacedModel model : input.models()) {
if (isTree(model)) { continue; }
int mx = wrp1(model.x(), wx0, rSize, targetSize);
int mz = wrp1(model.z(), wz0, rSize, targetSize);
gfx.fillRect(mx - dotR, mz - dotR, dotR * 2 + 1, dotR * 2 + 1);
@@ -680,13 +714,12 @@ public final class WorldMapRenderer {
// Gibt den R-, G- oder B-Kanal (channel=0/1/2) aller 8 Splatmap-Slots zurück.
// slotColorsRGB: 24 Werte (8 Slots × 3), 12 Werte (4 Slots, Upper-Layer = Defaults) oder null.
private static int[] slotChannel(RenderInput input, int channel) {
int[] rgb = input.slotColorsRGB();
private static int[] slotChannel(int[] slotColorsRGB, int channel) {
int[] def = channel == 0 ? DEF_SLOT_R : (channel == 1 ? DEF_SLOT_G : DEF_SLOT_B);
if (rgb == null || rgb.length < 12) { return def; }
if (slotColorsRGB == null || slotColorsRGB.length < 12) { return def; }
int[] out = new int[8];
for (int s = 0; s < 8; s++) {
out[s] = (rgb.length >= (s + 1) * 3) ? rgb[s * 3 + channel] : def[s];
out[s] = (slotColorsRGB.length >= (s + 1) * 3) ? slotColorsRGB[s * 3 + channel] : def[s];
}
return out;
}
@@ -802,4 +835,53 @@ public final class WorldMapRenderer {
float v01 = (arr[i01] & 0xFF) / 255f, v11 = (arr[i11] & 0xFF) / 255f;
return (v00*(1-tx) + v10*tx)*(1-tz) + (v01*(1-tx) + v11*tx)*tz;
}
public static boolean isTree(PlacedModel m) {
return m.modelPath().replace('\\', '/').toLowerCase().contains("/trees/");
}
/**
* Kuwahara-Filter: Für jeden Pixel das Quadranten-Fenster mit der kleinsten
* Varianz wählen und dessen Mittelwert setzen → Ölgemälde-/gemalt-Effekt.
* Radius r=3 → Fenster 7×7, 4 Quadranten je 4×4.
*/
private static void applyKuwahara(BufferedImage img, int r) {
int w = img.getWidth(), h = img.getHeight();
int[] src = img.getRGB(0, 0, w, h, null, 0, w);
int[] dst = new int[src.length];
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
float bestVar = Float.MAX_VALUE;
int bestPacked = src[y * w + x];
// 4 Quadranten: [xOff0..xOff1] × [yOff0..yOff1]
int[][] quads = {{-r,-r,0,0},{0,-r,r,0},{-r,0,0,r},{0,0,r,r}};
for (int[] q : quads) {
int x0 = Math.max(0, x+q[0]), y0 = Math.max(0, y+q[1]);
int x1 = Math.min(w-1, x+q[2]), y1 = Math.min(h-1, y+q[3]);
float sumR=0,sumG=0,sumB=0, sum2R=0,sum2G=0,sum2B=0;
int cnt = 0;
for (int qy = y0; qy <= y1; qy++) {
for (int qx = x0; qx <= x1; qx++) {
int p = src[qy * w + qx];
float pr = (p>>16)&0xFF, pg = (p>>8)&0xFF, pb = p&0xFF;
sumR+=pr; sumG+=pg; sumB+=pb;
sum2R+=pr*pr; sum2G+=pg*pg; sum2B+=pb*pb;
cnt++;
}
}
float inv = 1f / cnt;
float mR=sumR*inv, mG=sumG*inv, mB=sumB*inv;
float var = (sum2R*inv - mR*mR) + (sum2G*inv - mG*mG) + (sum2B*inv - mB*mB);
if (var < bestVar) {
bestVar = var;
bestPacked = (clamp((int)mR)<<16) | (clamp((int)mG)<<8) | clamp((int)mB);
}
}
dst[y * w + x] = bestPacked | 0xFF000000;
}
}
img.setRGB(0, 0, w, h, dst, 0, w);
}
}

View File

@@ -5639,11 +5639,36 @@ public class EditorApp extends Application {
});
worldMapView = new de.blight.editor.ui.WorldMapView(() -> primaryStage);
Tab weltkartTab = new Tab("Weltkarte", worldMapView);
worldMapView.setVoxelChunkSupplier(() -> {
de.blight.editor.state.VoxelEditorState ves = jmeApp == null ? null
: jmeApp.getStateManager().getState(de.blight.editor.state.VoxelEditorState.class);
return ves != null ? ves.getChunksSnapshot() : de.blight.common.VoxelChunkIO.loadAll();
});
worldMapView.setCameraInfoSupplier(() ->
new de.blight.editor.ui.WorldMapView.CameraInfo(
input.camX, input.camY, input.camZ, input.camYaw));
worldMapView.setTeleportCallback(pos -> {
input.pendingGotoX = pos[0];
input.pendingGotoY = pos[1];
input.pendingGotoZ = pos[2];
});
final Tab weltkartTab = new Tab("Weltkarte", worldMapView);
weltkartTab.setClosable(false);
weltkartTab.selectedProperty().addListener((obs, wasSelected, isSelected) -> {
if (isSelected && !worldMapView.isLoaded()) worldMapView.loadAndRender();
});
worldMapView.setFullscreenCallbacks(
() -> {
// Erst aus Tab lösen, dann in centerStack einsetzen
weltkartTab.setContent(new javafx.scene.control.Label(""));
setCenterView(worldMapView);
},
() -> {
// Erst aus centerStack lösen (worldViewport zurück), dann in Tab setzen
setCenterView(worldViewport);
weltkartTab.setContent(worldMapView);
});
TabPane tabPane = new TabPane(assetsTab, karteTab, weltkartTab);
tabPane.setStyle("-fx-background-color: #e8e8e8;");

View File

@@ -17,6 +17,9 @@
</encoder>
</appender>
<!-- Karten-Rendering: Modell-Build und PNG-Render auf DEBUG -->
<logger name="de.blight.editor.ui.WorldMapView" level="DEBUG"/>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>
<!-- GltfLoader meldet bei jeder Animation "only supports linear interpolation" bekanntes JME-Verhalten, kein Fehler -->

View File

@@ -533,6 +533,8 @@ public class MinimapState extends BaseAppState {
Files.createDirectories(dir);
Path png = dir.resolve("minimap_world.png");
log.debug("[Minimap] Lade Weltdaten für Render-Modell…");
long t0 = System.currentTimeMillis();
MapData mapData = MapIO.load();
List<PlacedArea> areas = AreaIO.load();
List<PlacedLocationZone> zones = LocationZoneIO.load();
@@ -541,6 +543,8 @@ public class MinimapState extends BaseAppState {
List<PlacedModel> models = PlacedModelIO.load();
int[] slotColors = computeSlotColors(mapData, root);
renderInput = new RenderInput(mapData, areas, zones, locs, waters, models, slotColors);
log.debug("[Minimap] Render-Modell bereit {} Areas, {} Orte, {} Wasser ({} ms)",
areas.size(), locs.size(), waters.size(), System.currentTimeMillis() - t0);
boolean needsRender = !Files.exists(png);
if (!needsRender) {
@@ -553,9 +557,11 @@ public class MinimapState extends BaseAppState {
}
if (needsRender) {
log.info("[Minimap] Rendere Weltkarte {}×{}…", TEXTURE_SIZE, TEXTURE_SIZE);
long t1 = System.currentTimeMillis();
BufferedImage bi = WorldMapRenderer.render(renderInput, TEXTURE_SIZE, RenderOptions.all());
ImageIO.write(bi, "PNG", png.toFile());
log.info("[Minimap] Weltkarte gespeichert: {}", png);
log.debug("[Minimap] Weltkarte gerendert und gespeichert ({} ms): {}",
System.currentTimeMillis() - t1, png);
} else {
log.info("[Minimap] Gecachte Weltkarte: {}", png);
}
@@ -645,8 +651,12 @@ public class MinimapState extends BaseAppState {
final float capHU = vr / WORLD_SIZE;
Thread t = new Thread(() -> {
log.debug("[Minimap] Vektor-Layer neu rendern Zentrum ({}/{}), Radius {}",
Math.round(wx), Math.round(wz), Math.round(vr));
long t0 = System.currentTimeMillis();
RenderOptions opts = new RenderOptions(false, false, true, true, true, true, true);
BufferedImage bi = WorldMapRenderer.renderRegion(renderInput, VEC_TEX_SIZE, opts, wx, wz, vr);
log.debug("[Minimap] Vektor-Layer fertig ({} ms)", System.currentTimeMillis() - t0);
app.enqueue(() -> {
updateVectorTexture(bi);
vecRenderU = capU;

View File

@@ -19,6 +19,8 @@
<!-- LOD-Slot-Wechsel auf DEBUG aktivieren -->
<logger name="de.blight.game.state.ModelLodControl" level="DEBUG"/>
<!-- Minimap: Modell-Build und Render-Zyklen auf DEBUG -->
<logger name="de.blight.game.state.MinimapState" level="DEBUG"/>
<!-- JME-interne JUL-Logs auf WARN reduzieren -->
<logger name="com.jme3" level="WARN"/>

View File

@@ -0,0 +1,9 @@
{
"id": "b26f42df-7897-4840-9e7f-5f35329aa0a6",
"benchType": "Simple",
"sitzX": 0.30349,
"sitzY": 2.5,
"sitzZ": -5.00573,
"sitzRotY": 1.5707964,
"sitzSet": true
}

View File

@@ -11,3 +11,5 @@ Models/trees/palm/palm_20260816_213341.j3o 270.73062 3.06689 -913.67090 -1.45828
Models/imported/bank1.j3o 236.63928 -6.31074 -888.17450 -3.22597 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH 9ac9943d-0e12-4d5c-8323-0e2b92eebdec
Models/trees/willow/willow_20260823_101850.j3o 152.40488 11.49070 -888.86847 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/trees/willow/willow_20260823_101856.j3o 170.68971 11.48961 -873.92236 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/imported/wolf.j3o -1.25617 2.00000 -1.86424 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000
Models/imported/bank1.j3o 0.30349 2.00000 -5.00573 0.00000 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 BENCH b26f42df-7897-4840-9e7f-5f35329aa0a6