Bugfixes, weitere Vegetationsgeneratoren hinzugefügt

This commit is contained in:
2026-07-25 20:16:13 +02:00
parent 574bad3d67
commit d7aeb890c9
66 changed files with 2379 additions and 173 deletions

View File

@@ -105,6 +105,14 @@ public class EditorApp extends Application {
// Farn-Generator-Zustand
private de.blight.editor.tree.FernOptions fernOptions = new de.blight.editor.tree.FernOptions();
// Fruchtbusch-Generator-Zustand
private de.blight.editor.tree.FruitBushOptions fruitBushOptions =
de.blight.editor.tree.FruitBushOptions.orange();
private String fruitBushVariant = "Orange";
// Weinpflanzen-Generator-Zustand
private de.blight.editor.tree.GrapevineOptions grapevineOptions = new de.blight.editor.tree.GrapevineOptions();
// Vegetations-Generator-Zustand
private String vegetationType = "Baum (Eiche)";
@@ -321,8 +329,8 @@ public class EditorApp extends Application {
private ToggleButton playToolBtn;
private ToggleButton voxelBtn;
private ToggleButton voxelCliffBtn;
private ToggleButton camOrbitBtn;
private ToggleButton camFreeBtn;
private RadioMenuItem camOrbitItem;
private RadioMenuItem camFreeItem;
// "Objekt"-Button in der Selektionsleiste (zum Zurückschalten bei importierten Objekten)
private ToggleButton selModeObjectBtn;
@@ -410,7 +418,20 @@ public class EditorApp extends Application {
Scene scene = new Scene(root, 1280, 760);
java.net.URL cssUrl = getClass().getResource("/editor.css");
if (cssUrl != null) scene.getStylesheets().add(cssUrl.toExternalForm());
scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> handleKeyPress(e.getCode(), true));
scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.C && e.isAltDown()) {
e.consume();
if (input.camMode == SharedInput.CAM_ORBIT) {
input.camMode = SharedInput.CAM_FREEFLY;
if (camFreeItem != null) { camFreeItem.setSelected(true); }
} else {
input.camMode = SharedInput.CAM_ORBIT;
if (camOrbitItem != null) { camOrbitItem.setSelected(true); }
}
return;
}
handleKeyPress(e.getCode(), true);
});
scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_RELEASED, e -> handleKeyPress(e.getCode(), false));
scene.setOnKeyTyped(e -> {
if (!input.consoleIsOpen) return;
@@ -962,7 +983,7 @@ public class EditorApp extends Application {
ComboBox<String> typeBox = new ComboBox<>();
typeBox.getItems().addAll(
"Baum (Eiche)", "Baum (Birke)", "Baum (Kiefer)", "Baum (Weide)", "Baum (Busch)",
"Farn", "Palme");
"Farn", "Palme", "Fruchtbusch", "Weinpflanze");
typeBox.setValue(vegetationType);
typeBox.setOnAction(e -> {
vegetationType = typeBox.getValue();
@@ -986,6 +1007,10 @@ public class EditorApp extends Application {
treeParams.copy(), true, treeTypeFromPreset(currentTreePreset)));
} else if ("Farn".equals(vegetationType)) {
input.fernGenQueue.offer(new SharedInput.FernGenRequest(fernOptions.copy(), true));
} else if ("Fruchtbusch".equals(vegetationType)) {
input.fruitBushGenQueue.offer(new SharedInput.FruitBushGenRequest(fruitBushOptions.copy(), fruitBushVariant, true));
} else if ("Weinpflanze".equals(vegetationType)) {
input.grapevineGenQueue.offer(new SharedInput.GrapevineGenRequest(grapevineOptions.copy(), true));
} else {
input.palmGenQueue.offer(new SharedInput.PalmGenRequest(palmOptions.copy(), true));
}
@@ -1027,6 +1052,28 @@ public class EditorApp extends Application {
root.setRight(buildVegetationParamsPanel());
onF5.run();
};
} else if ("Fruchtbusch".equals(vegetationType)) {
onF5 = () -> {
input.fruitBushGenQueue.offer(
new SharedInput.FruitBushGenRequest(fruitBushOptions.copy(), fruitBushVariant, false));
setStatus("Fruchtbusch: generiere Vorschau…");
};
onF6 = () -> {
fruitBushOptions.seed = new java.util.Random().nextInt(1000000);
root.setRight(buildVegetationParamsPanel());
onF5.run();
};
} else if ("Weinpflanze".equals(vegetationType)) {
onF5 = () -> {
input.grapevineGenQueue.offer(
new SharedInput.GrapevineGenRequest(grapevineOptions.copy(), false));
setStatus("Weinpflanze: generiere Vorschau…");
};
onF6 = () -> {
grapevineOptions.seed = new java.util.Random().nextInt(1000000);
root.setRight(buildVegetationParamsPanel());
onF5.run();
};
} else {
onF5 = () -> {
input.palmGenQueue.offer(new SharedInput.PalmGenRequest(palmOptions.copy(), false));
@@ -1045,6 +1092,10 @@ public class EditorApp extends Application {
return buildTreeParamsPanel();
} else if ("Farn".equals(vegetationType)) {
return buildFernParamsPanel();
} else if ("Fruchtbusch".equals(vegetationType)) {
return buildFruitBushParamsPanel();
} else if ("Weinpflanze".equals(vegetationType)) {
return buildGrapevineParamsPanel();
} else {
return buildPalmParamsPanel();
}
@@ -1114,6 +1165,113 @@ public class EditorApp extends Application {
return panel;
}
private VBox buildFruitBushParamsPanel() {
VBox inner = new VBox(8);
inner.setPadding(new Insets(10));
// ── Variante ──────────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Fruchtbusch-Typ"), new Separator());
ComboBox<String> variantBox = new ComboBox<>();
variantBox.getItems().addAll("Orange", "Zitrone", "Aprikose");
variantBox.setValue(fruitBushVariant);
variantBox.setMaxWidth(Double.MAX_VALUE);
variantBox.setOnAction(e -> {
fruitBushVariant = variantBox.getValue();
int oldSeed = fruitBushOptions.seed;
fruitBushOptions = switch (fruitBushVariant) {
case "Zitrone" -> de.blight.editor.tree.FruitBushOptions.lemon();
case "Aprikose" -> de.blight.editor.tree.FruitBushOptions.apricot();
default -> de.blight.editor.tree.FruitBushOptions.orange();
};
fruitBushOptions.seed = oldSeed;
root.setRight(buildVegetationParamsPanel());
if (onF5 != null) onF5.run();
});
inner.getChildren().add(variantBox);
// ── Allgemein ─────────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Allgemein"), new Separator());
inner.getChildren().add(bold("Zufallssamen:"));
Spinner<Integer> seedSp = intSpinner(0, 999999, fruitBushOptions.seed);
seedSp.valueProperty().addListener((o, a, b) -> fruitBushOptions.seed = b);
Button rndSeedBtn = new Button("🎲");
rndSeedBtn.setOnAction(e -> {
int s = new java.util.Random().nextInt(1000000);
fruitBushOptions.seed = s;
seedSp.getValueFactory().setValue(s);
if (onF5 != null) onF5.run();
});
HBox seedRow = new HBox(4, seedSp, rndSeedBtn);
HBox.setHgrow(seedSp, Priority.ALWAYS);
inner.getChildren().add(seedRow);
// ── Stamm ─────────────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Stamm"), new Separator());
inner.getChildren().add(ezFloat("Höhe (m):", 0.2, 1.5, fruitBushOptions.trunkHeight,
v -> fruitBushOptions.trunkHeight = v));
inner.getChildren().add(ezFloat("Radius (m):", 0.01, 0.15, fruitBushOptions.trunkRadius,
v -> fruitBushOptions.trunkRadius = v));
// ── Äste ──────────────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Äste"), new Separator());
inner.getChildren().add(bold("Anzahl Primäräste:"));
Spinner<Integer> branchCountSp = intSpinner(2, 10, fruitBushOptions.branchCount);
branchCountSp.valueProperty().addListener((o, a, b) -> fruitBushOptions.branchCount = b);
inner.getChildren().add(branchCountSp);
inner.getChildren().add(ezFloat("Winkel (°):", 10, 80, fruitBushOptions.branchAngle,
v -> fruitBushOptions.branchAngle = v));
inner.getChildren().add(ezFloat("Länge (m):", 0.2, 2.0, fruitBushOptions.branchLength,
v -> fruitBushOptions.branchLength = v));
inner.getChildren().add(bold("Reiser pro Ast:"));
Spinner<Integer> twigCountSp = intSpinner(1, 8, fruitBushOptions.twigCount);
twigCountSp.valueProperty().addListener((o, a, b) -> fruitBushOptions.twigCount = b);
inner.getChildren().add(twigCountSp);
inner.getChildren().add(ezFloat("Reiser-Winkel (°):", 10, 80, fruitBushOptions.twigAngle,
v -> fruitBushOptions.twigAngle = v));
inner.getChildren().add(ezFloat("Reiser-Länge (m):", 0.1, 1.2, fruitBushOptions.twigLength,
v -> fruitBushOptions.twigLength = v));
// ── Blätter & Früchte ─────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Blätter & Früchte"), new Separator());
inner.getChildren().add(ezFloat("Blattgröße (m):", 0.05, 0.6, fruitBushOptions.leafSize,
v -> fruitBushOptions.leafSize = v));
inner.getChildren().add(bold("Blätter pro Reiser:"));
Spinner<Integer> leafCountSp = intSpinner(2, 20, fruitBushOptions.leafCount);
leafCountSp.valueProperty().addListener((o, a, b) -> fruitBushOptions.leafCount = b);
inner.getChildren().add(leafCountSp);
inner.getChildren().add(ezFloat("Frucht-Dichte [01]:", 0.0, 1.0, fruitBushOptions.fruitDensity,
v -> fruitBushOptions.fruitDensity = v));
inner.getChildren().add(ezFloat("Frucht-Radius (m):", 0.02, 0.15, fruitBushOptions.fruitRadius,
v -> fruitBushOptions.fruitRadius = v));
// ── Wind ──────────────────────────────────────────────────────────────
inner.getChildren().addAll(sectionTitle("Wind"), new Separator());
inner.getChildren().add(ezFloat("Windstärke:", 0.0, 0.5, fruitBushOptions.windStrength,
v -> fruitBushOptions.windStrength = v));
inner.getChildren().add(ezFloat("Windgeschwindigkeit:", 0.1, 2.0, fruitBushOptions.windSpeed,
v -> fruitBushOptions.windSpeed = v));
Button previewBtn = new Button("▶ Vorschau");
previewBtn.setMaxWidth(Double.MAX_VALUE);
previewBtn.setStyle("-fx-background-color:#2d8a3e;-fx-text-fill:white;-fx-font-weight:bold;-fx-padding:6 12 6 12;");
previewBtn.setOnAction(e -> { if (onF5 != null) onF5.run(); });
inner.getChildren().addAll(new Separator(), previewBtn);
ScrollPane scroll = new ScrollPane(inner);
scroll.setFitToWidth(true);
scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;");
VBox panel = new VBox(scroll);
VBox.setVgrow(scroll, Priority.ALWAYS);
panel.setPrefWidth(270);
panel.setStyle("-fx-background-color:#f0f0f0;-fx-border-color:#ccc;-fx-border-width:0 0 0 1;");
return panel;
}
private void switchToTripo() {
currentTool = "tripo";
topBar.getChildren().set(1, buildTripoToolBar());
@@ -1209,8 +1367,19 @@ public class EditorApp extends Application {
});
viewTopologyItem.setOnAction(e ->
input.topologyRequest = viewTopologyItem.isSelected() ? 1 : 2);
ToggleGroup camMenuGroup = new ToggleGroup();
camOrbitItem = new RadioMenuItem("⊙ Orbit-Kamera (Alt+C)");
camFreeItem = new RadioMenuItem("✈ FreeFly-Kamera (Alt+C)");
camOrbitItem.setToggleGroup(camMenuGroup);
camFreeItem.setToggleGroup(camMenuGroup);
camOrbitItem.setSelected(true);
camOrbitItem.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeItem.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
viewMenu.getItems().addAll(resetCam, new SeparatorMenuItem(), viewTexture, viewWireframe,
new SeparatorMenuItem(), viewTopologyItem);
new SeparatorMenuItem(), viewTopologyItem,
new SeparatorMenuItem(), camOrbitItem, camFreeItem);
Menu zeitMenu = new Menu("Zeit");
ToggleGroup zeitGroup = new ToggleGroup();
@@ -1240,11 +1409,9 @@ public class EditorApp extends Application {
areaBtn = new ToggleButton("🗺 Bereiche");
locationZoneBtn = new ToggleButton("📍 Locations");
playToolBtn = new ToggleButton("🎮 Spielen");
voxelBtn = new ToggleButton("⬡ Voxel");
voxelCliffBtn = new ToggleButton("⛰ Klippe");
voxelBtn = new ToggleButton("⬡ Voxel");
voxelCliffBtn = new ToggleButton("⛰ Klippe");
stoneBtn = new ToggleButton("🪨 Steine");
camOrbitBtn = new ToggleButton("⊙ Orbit");
camFreeBtn = new ToggleButton("✈ FreeFly");
baseBtn.setStyle("-fx-font-weight:bold;");
grassBtn.setStyle("-fx-font-weight:bold;");
grassVertexBtn.setStyle("-fx-font-weight:bold;");
@@ -1262,8 +1429,6 @@ public class EditorApp extends Application {
voxelBtn.setStyle("-fx-font-weight:bold;");
voxelCliffBtn.setStyle("-fx-font-weight:bold;");
stoneBtn.setStyle("-fx-font-weight:bold;");
camOrbitBtn.setStyle("-fx-font-weight:bold;");
camFreeBtn.setStyle("-fx-font-weight:bold;");
ToggleGroup layerGroup = new ToggleGroup();
baseBtn.setToggleGroup(layerGroup);
@@ -1286,6 +1451,9 @@ public class EditorApp extends Application {
baseBtn.setSelected(true);
baseBtn.setOnAction(e -> {
input.heightTool.brushRadius.setValue(input.voxelTool.brushRadius.getValue());
input.heightTool.brushStrength.setValue(input.voxelTool.brushStrength.getValue());
input.heightTool.plateauHeight.setValue(input.voxelTool.plateauTarget.getValue());
input.activeLayer = 0; input.activeTool = input.heightTool;
root.setRight(toolPanel);
showToolParameters(toolPanel, input.activeTool);
@@ -1352,6 +1520,9 @@ public class EditorApp extends Application {
root.setRight(buildPlayToolPanel());
});
voxelBtn.setOnAction(e -> {
input.voxelTool.brushRadius.setValue(input.heightTool.brushRadius.getValue());
input.voxelTool.brushStrength.setValue(input.heightTool.brushStrength.getValue());
input.voxelTool.plateauTarget.setValue(input.heightTool.plateauHeight.getValue());
input.activeLayer = SharedInput.LAYER_VOXEL;
input.activeTool = input.voxelTool;
root.setRight(toolPanel);
@@ -1368,13 +1539,6 @@ public class EditorApp extends Application {
showToolParameters(toolPanel, input.activeTool);
});
ToggleGroup camModeGroup = new ToggleGroup();
camOrbitBtn.setToggleGroup(camModeGroup);
camFreeBtn.setToggleGroup(camModeGroup);
camOrbitBtn.setSelected(true);
camOrbitBtn.setOnAction(e -> input.camMode = SharedInput.CAM_ORBIT);
camFreeBtn.setOnAction(e -> input.camMode = SharedInput.CAM_FREEFLY);
Button camResetBtn = new Button("⌂ Reset");
camResetBtn.setStyle("-fx-font-weight:bold;");
camResetBtn.setTooltip(new javafx.scene.control.Tooltip("Kamera auf x=0, z=0, y=Terrain+10m zurücksetzen"));
@@ -1393,7 +1557,7 @@ public class EditorApp extends Application {
new Separator(Orientation.VERTICAL), playToolBtn,
new Separator(Orientation.VERTICAL), voxelBtn, voxelCliffBtn,
new Separator(Orientation.VERTICAL), stoneBtn,
new Separator(Orientation.VERTICAL), camOrbitBtn, camFreeBtn, camResetBtn,
new Separator(Orientation.VERTICAL), camResetBtn,
new Separator(Orientation.VERTICAL), hint);
worldToolBar = toolBar;
@@ -1449,6 +1613,76 @@ public class EditorApp extends Application {
return plantPreviewPanel;
}
// ── Weinpflanzen-Generator rechtes Parameter-Panel ─────────────────────
private VBox buildGrapevineParamsPanel() {
VBox inner = new VBox(8);
inner.setPadding(new Insets(10));
inner.getChildren().addAll(sectionTitle("Allgemein"), new Separator());
inner.getChildren().add(bold("Zufallssamen:"));
Spinner<Integer> seedSp = intSpinner(0, 999999, grapevineOptions.seed);
seedSp.valueProperty().addListener((o, a, b) -> grapevineOptions.seed = b);
Button rndSeedBtn = new Button("🎲");
rndSeedBtn.setOnAction(e -> {
int s = new java.util.Random().nextInt(1000000);
grapevineOptions.seed = s;
seedSp.getValueFactory().setValue(s);
if (onF5 != null) onF5.run();
});
HBox seedRow = new HBox(4, seedSp, rndSeedBtn);
HBox.setHgrow(seedSp, Priority.ALWAYS);
inner.getChildren().add(seedRow);
inner.getChildren().addAll(sectionTitle("Rahmen"), new Separator());
inner.getChildren().add(ezFloat("Gesamtbreite (m):", 2.0, 10.0, grapevineOptions.totalWidth,
v -> grapevineOptions.totalWidth = v));
inner.getChildren().add(ezFloat("Gesamthöhe (m):", 1.5, 4.0, grapevineOptions.totalHeight,
v -> grapevineOptions.totalHeight = v));
inner.getChildren().add(ezFloat("1. Draht-Höhe (m):", 0.5, 2.0, grapevineOptions.firstWireH,
v -> grapevineOptions.firstWireH = v));
inner.getChildren().add(bold("Draht-Anzahl:"));
Spinner<Integer> wireCountSp = intSpinner(2, 6, grapevineOptions.wireCount);
wireCountSp.valueProperty().addListener((o, a, b) -> grapevineOptions.wireCount = b);
inner.getChildren().add(wireCountSp);
inner.getChildren().addAll(sectionTitle("Triebe & Blätter"), new Separator());
inner.getChildren().add(bold("Triebe pro Seite:"));
Spinner<Integer> shootCountSp = intSpinner(2, 14, grapevineOptions.shootCount);
shootCountSp.valueProperty().addListener((o, a, b) -> grapevineOptions.shootCount = b);
inner.getChildren().add(shootCountSp);
inner.getChildren().add(ezFloat("Blattgröße (m):", 0.10, 0.60, grapevineOptions.leafSize,
v -> grapevineOptions.leafSize = v));
inner.getChildren().add(bold("Blätter pro Trieb:"));
Spinner<Integer> leafCountSp = intSpinner(2, 128, grapevineOptions.leafCount);
leafCountSp.valueProperty().addListener((o, a, b) -> grapevineOptions.leafCount = b);
inner.getChildren().add(leafCountSp);
inner.getChildren().addAll(sectionTitle("Wind"), new Separator());
inner.getChildren().add(ezFloat("Windstärke:", 0.0, 0.5, grapevineOptions.windStrength,
v -> grapevineOptions.windStrength = v));
inner.getChildren().add(ezFloat("Windgeschwindigkeit:", 0.1, 2.0, grapevineOptions.windSpeed,
v -> grapevineOptions.windSpeed = v));
Button previewBtn = new Button("▶ Vorschau");
previewBtn.setMaxWidth(Double.MAX_VALUE);
previewBtn.setStyle("-fx-background-color:#2d8a3e;-fx-text-fill:white;-fx-font-weight:bold;-fx-padding:6 12 6 12;");
previewBtn.setOnAction(e -> { if (onF5 != null) onF5.run(); });
inner.getChildren().add(new Separator());
inner.getChildren().add(previewBtn);
ScrollPane scroll = new ScrollPane(inner);
scroll.setFitToWidth(true);
scroll.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scroll.setStyle("-fx-background-color:transparent;-fx-background:transparent;");
VBox panel = new VBox(scroll);
VBox.setVgrow(scroll, Priority.ALWAYS);
panel.setPrefWidth(270);
panel.setStyle("-fx-background-color:#f0f0f0;-fx-border-color:#ccc;-fx-border-width:0 0 0 1;");
return panel;
}
// ── Baum-Generator rechtes Parameter-Panel ─────────────────────────────
private VBox buildTreeParamsPanel() {
@@ -1846,7 +2080,7 @@ public class EditorApp extends Application {
&& palmOptions.leafTexture.contains("palm2") ? "palm2.png" : "palm.png";
leafTexBox.setValue(currentLeaf);
leafTexBox.setMaxWidth(Double.MAX_VALUE);
leafTexBox.setOnAction(e -> palmOptions.leafTexture = "Textures/internal/leaves/" + leafTexBox.getValue());
leafTexBox.setOnAction(e -> palmOptions.leafTexture = "Textures/internal/foliage/" + leafTexBox.getValue());
inner.getChildren().add(new VBox(2, leafTexLabel, leafTexBox));
// Rinden-Textur-Auswahl (alle Texturen aus dem bark-Ordner)
@@ -3260,7 +3494,9 @@ public class EditorApp extends Application {
new javafx.scene.control.Separator(),
scaleLbl, globalScaleSpin,
new javafx.scene.control.Separator(),
slotsScroll);
slotsScroll,
new javafx.scene.control.Separator(),
buildVoxelSplatSectionUI());
}
// Textur-Picker (nur beim GrassTool)
@@ -3714,6 +3950,39 @@ public class EditorApp extends Application {
return box;
}
private javafx.scene.Node buildVoxelSplatSectionUI() {
VBox box = new VBox(4);
Label title = new Label("Voxel-Splatmap (Slots 24)");
title.setStyle("-fx-font-weight: bold; -fx-text-fill: #111; -fx-font-size: 11;");
Label hint = new Label("Slot 1 (Basis) = Voxel-Flat-Slot oben");
hint.setStyle("-fx-text-fill: #888; -fx-font-size: 10;");
box.getChildren().addAll(title, hint);
String[] slotLabels = {"Slot 2 (G)", "Slot 3 (B)", "Slot 4 (A)"};
for (int i = 1; i <= 3; i++) {
final int si = i;
String[] paths = input.voxelSplatTexturePaths;
String cur = (paths != null && i < paths.length && paths[i] != null) ? paths[i] : "";
Label nameLbl = new Label(slotLabels[i - 1] + ": " + labelFromPath(cur));
nameLbl.setStyle("-fx-font-size: 10; -fx-text-fill: #444;");
nameLbl.setMaxWidth(180);
Button chooseBtn = new Button("...");
chooseBtn.setOnAction(e -> {
de.blight.editor.ui.TextureChooser chooser =
new de.blight.editor.ui.TextureChooser(ASSET_ROOT, false);
chooser.showAndWait().ifPresent(chosenPath -> {
String[] copy = input.voxelSplatTexturePaths.clone();
copy[si] = chosenPath;
input.voxelSplatTexturePaths = copy;
input.voxelSplatTexturesChanged = true;
nameLbl.setText(slotLabels[si - 1] + ": " + labelFromPath(chosenPath));
});
});
box.getChildren().add(new HBox(4, nameLbl, chooseBtn));
}
return box;
}
private javafx.scene.Node buildVoxelTextureChoiceUI(ChoiceToolParameter param) {
ToggleGroup tg = new ToggleGroup();
javafx.scene.layout.TilePane tile = new javafx.scene.layout.TilePane();
@@ -6624,7 +6893,8 @@ public class EditorApp extends Application {
input.modelEditorScaleY = 1f;
input.modelEditorScaleZ = 1f;
input.modelEditorPivotY = 0f;
input.modelEditorOpenPath = relPath;
input.modelEditorOpenPath = relPath;
input.modelImportIntermediatePath = relPath;
root.setRight(buildModelImportPanel(relPath, suggestedName));
setStatus("Modell-Import: " + relPath);
@@ -7549,7 +7819,11 @@ public class EditorApp extends Application {
case 3 -> input.grassEditQueue.offer(new SharedInput.GrassEdit((float) x, (float) y, action));
case SharedInput.LAYER_GRASS_VERTEX ->
input.grassVertexEditQueue.offer(new SharedInput.GrassVertexEdit((float) x, (float) y, action));
case 4 -> input.textureEditQueue.offer(new SharedInput.TextureEdit((float) x, (float) y, action));
case 4 -> {
SharedInput.TextureEdit te = new SharedInput.TextureEdit((float) x, (float) y, action);
input.textureEditQueue.offer(te);
input.voxelTextureEditQueue.offer(te);
}
case SharedInput.LAYER_LIGHTS ->
input.lightClickQueue.offer(new SharedInput.LightClick((float) x, (float) y, action < 0));
case SharedInput.LAYER_EMITTERS ->

View File

@@ -30,6 +30,8 @@ import de.blight.editor.state.WaterBodyState;
import de.blight.editor.state.EzTreeState;
import de.blight.editor.state.LightState;
import de.blight.editor.state.FernGeneratorState;
import de.blight.editor.state.FruitBushGeneratorState;
import de.blight.editor.state.GrapevineGeneratorState;
import de.blight.editor.state.PalmGeneratorState;
import de.blight.editor.state.SceneObjectState;
import de.blight.editor.state.TerrainEditorState;
@@ -189,6 +191,8 @@ public class JmeEditorApp extends SimpleApplication {
stateManager.attach(new EzTreeState(input));
stateManager.attach(new PalmGeneratorState(input));
stateManager.attach(new FernGeneratorState(input));
stateManager.attach(new FruitBushGeneratorState(input));
stateManager.attach(new GrapevineGeneratorState(input));
stateManager.attach(new LightState(input));
stateManager.attach(new EmitterState(input));
stateManager.attach(new WaterBodyState(input));

View File

@@ -22,11 +22,12 @@ import java.util.Map;
public class LegacyAssetRedirectLocator implements AssetLocator {
private static final Map<String, String> PREFIXES = Map.of(
"Textures/bark/", "Textures/internal/bark/",
"Textures/leaves/", "Textures/internal/leaves/",
"Textures/fern/", "Textures/internal/fern/",
"Textures/water/", "Textures/internal/water/",
"Textures/Water/", "Textures/internal/water/"
"Textures/bark/", "Textures/internal/bark/",
"Textures/leaves/", "Textures/internal/foliage/",
"Textures/internal/leaves/", "Textures/internal/foliage/",
"Textures/fern/", "Textures/internal/fern/",
"Textures/water/", "Textures/internal/water/",
"Textures/Water/", "Textures/internal/water/"
);
private Path root;

View File

@@ -100,6 +100,14 @@ public class SharedInput {
public record FernGenRequest(de.blight.editor.tree.FernOptions options, boolean exportAfter) {}
public final ConcurrentLinkedQueue<FernGenRequest> fernGenQueue = new ConcurrentLinkedQueue<>();
// ── Fruchtbusch-Generator ─────────────────────────────────────────────────
public record FruitBushGenRequest(de.blight.editor.tree.FruitBushOptions options, String presetName, boolean exportAfter) {}
public final ConcurrentLinkedQueue<FruitBushGenRequest> fruitBushGenQueue = new ConcurrentLinkedQueue<>();
// ── Weinpflanzen-Generator ────────────────────────────────────────────────
public record GrapevineGenRequest(de.blight.editor.tree.GrapevineOptions options, boolean exportAfter) {}
public final ConcurrentLinkedQueue<GrapevineGenRequest> grapevineGenQueue = new ConcurrentLinkedQueue<>();
// ── Gras-Einstellungen (JavaFX → JME3) ───────────────────────────────────
/** Relativer Asset-Pfad der Gras-Textur ("" = Standardfarbe). */
public volatile String grassTexturePath = "";
@@ -868,6 +876,8 @@ public class SharedInput {
public volatile String modelImportExportName = null;
/** JME → JFX: Status-Meldung nach dem Export (relativer Pfad oder "FEHLER: …"). */
public volatile String modelImportExportStatus = null;
/** JFX: Pfad der Zwischen-.j3o-Datei (relativ zu ASSET_ROOT). Wird von ModelImportState nach Export gelöscht, nicht von ModelEditorState. */
public volatile String modelImportIntermediatePath = null;
// ── Mesh-Sculpting ───────────────────────────────────────────────────────
/** activeLayer==24 → gebackene Voxel-Meshes direkt sculpten */
@@ -1040,4 +1050,14 @@ public class SharedInput {
public volatile float cliffRoughnessAmp = 4.0f;
/** 3D-Noise-Frequenz für die Kliff-Oberfläche (höher = feiner Detail). */
public volatile float cliffRoughnessScale = 0.12f;
// ── Voxel-Textur-Malen ────────────────────────────────────────────────────
/** Parallel-Queue zu textureEditQueue wird von submitEdit(layer=4) mitbefüllt,
* damit VoxelEditorState unabhängig vom Terrain seine Splatmap beschreiben kann. */
public final ConcurrentLinkedQueue<TextureEdit> voxelTextureEditQueue = new ConcurrentLinkedQueue<>();
/** Pfade der 4 Voxel-Splatmap-Slots (Slot 0 = Basis/TexFlat, Slots 1-3 = G/B/A). */
public volatile String[] voxelSplatTexturePaths = new String[]{"", "", "", ""};
/** JFX setzt true wenn Voxel-Splat-Texturen geändert wurden; JME liest + resettet. */
public volatile boolean voxelSplatTexturesChanged = false;
}

View File

@@ -449,6 +449,16 @@ public class EzTreeState extends BaseAppState {
Node lodRoot = assembleLodNode(req.presetName(), hdNode, ld1Node, bb, atlasT2d);
exportTree(lodRoot, exportName, subPath);
// assembleLodNode hat hdNode aus previewTreeHolder entnommen (JME3: nur ein Parent).
// Nach dem Export die Vorschau wiederherstellen.
float camDist = bb != null
? Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3.2f
: 20f;
Vector3f target = bb != null
? new Vector3f(0f, bb.getCenter().y, 0f)
: new Vector3f(0f, 5f, 0f);
previewHost.setPreviewContent(hdNode, camDist, target);
}
// ── Material-Aufbau ───────────────────────────────────────────────────────
@@ -466,6 +476,7 @@ public class EzTreeState extends BaseAppState {
g.setQueueBucket(RenderQueue.Bucket.Transparent);
g.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
}
}
} else if (child instanceof Node trellis) {
Material mat = buildBarkMat(opts);

View File

@@ -0,0 +1,199 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.texture.Texture;
import de.blight.editor.SharedInput;
import de.blight.editor.tree.FruitBushMeshBuilder;
import de.blight.editor.tree.FruitBushOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class FruitBushGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(FruitBushGeneratorState.class);
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve(
"blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private TreeGeneratorState previewHost;
public FruitBushGeneratorState(SharedInput input) { this.input = input; }
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
}
@Override protected void cleanup(Application app) {}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
if (previewHost == null) {
previewHost = getStateManager().getState(TreeGeneratorState.class);
if (previewHost == null) return;
}
SharedInput.FruitBushGenRequest req = input.fruitBushGenQueue.poll();
if (req == null) return;
FruitBushOptions opts = req.options();
FruitBushMeshBuilder.MeshResult result = FruitBushMeshBuilder.build(opts);
Node bush = assembleBushNode(result, opts);
bush.updateGeometricState();
BoundingBox bb = bush.getWorldBound() instanceof BoundingBox b ? b : null;
float dist = bb != null
? Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 4f
: 3f;
Vector3f target = bb != null
? new Vector3f(0f, bb.getCenter().y, 0f)
: new Vector3f(0f, 1f, 0f);
final Node finalBush = bush;
final float finalDist = dist;
final Vector3f finalTarget = target;
final boolean doExport = req.exportAfter();
final String presetName = req.presetName();
app.enqueue(() -> {
previewHost.setPreviewContent(finalBush, finalDist, finalTarget);
if (doExport) {
exportBush(finalBush, presetName);
}
});
input.treeGenStatusMsg = doExport ? "Fruchtbusch: exportiere…" : "Fruchtbusch: Vorschau";
}
private Node assembleBushNode(FruitBushMeshBuilder.MeshResult result, FruitBushOptions opts) {
Node node = new Node("fruchtbusch");
Geometry bark = new Geometry("bark", result.bark());
bark.setMaterial(buildBarkMat(opts));
bark.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
node.attachChild(bark);
if (result.leaves().getVertexCount() > 0) {
Geometry leaves = new Geometry("leaves", result.leaves());
leaves.setMaterial(buildLeafMat(opts));
leaves.setQueueBucket(RenderQueue.Bucket.Transparent);
leaves.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
node.attachChild(leaves);
}
if (opts.fruitsEnabled && result.fruits().getVertexCount() > 0) {
Geometry fruits = new Geometry("fruits", result.fruits());
fruits.setMaterial(buildFruitMat(opts));
fruits.setQueueBucket(RenderQueue.Bucket.Transparent);
fruits.setShadowMode(RenderQueue.ShadowMode.Off);
node.attachChild(fruits);
}
return node;
}
private Material buildBarkMat(FruitBushOptions opts) {
try {
Material mat = new Material(assets, "MatDefs/Tree.j3md");
mat.setColor("Diffuse", new ColorRGBA(0.72f, 0.60f, 0.45f, 1f));
mat.setFloat("WindStrength", opts.windStrength * 0.4f);
mat.setFloat("WindSpeed", opts.windSpeed);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
try {
Texture barkTex = assets.loadTexture("Textures/internal/bark/Bark001_Color.jpg");
barkTex.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("BarkMap", barkTex);
mat.setBoolean("HasBarkMap", true);
} catch (Exception ignored) {}
return mat;
} catch (Exception e) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.45f, 0.32f, 0.18f, 1f));
return mat;
}
}
private Material buildLeafMat(FruitBushOptions opts) {
try {
Material mat = new Material(assets, "MatDefs/TreeLeaf.j3md");
mat.setColor("Diffuse", new ColorRGBA(opts.leafR, opts.leafG, opts.leafB, 1f));
mat.setFloat("WindStrength", opts.windStrength);
mat.setFloat("WindSpeed", opts.windSpeed);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
if (opts.leafTexture != null) {
try {
mat.setTexture("LeafMap", assets.loadTexture(opts.leafTexture));
mat.setBoolean("HasLeafMap", true);
} catch (Exception ignored) {}
}
return mat;
} catch (Exception e) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(opts.leafR, opts.leafG, opts.leafB, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
}
private Material buildFruitMat(FruitBushOptions opts) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(opts.fruitR, opts.fruitG, opts.fruitB, 1f));
if (opts.fruitTexture != null) {
try {
Texture tex = assets.loadTexture(opts.fruitTexture);
tex.setWrap(Texture.WrapMode.Clamp);
mat.setTexture("ColorMap", tex);
} catch (Exception ignored) {}
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
private void exportBush(Node bush, String presetName) {
try {
String folder = presetName == null ? "fruchtbusch"
: presetName.toLowerCase().replace("ä", "ae").replace("ö", "oe")
.replace("ü", "ue").replace(" ", "_");
String ts = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String name = folder + "_" + ts;
Path dir = ASSET_ROOT.resolve("Models").resolve("trees").resolve(folder);
Files.createDirectories(dir);
Path out = dir.resolve(name + ".j3o");
BinaryExporter.getInstance().save(bush, out.toFile());
log.info("[Fruchtbusch] Gespeichert: {}", out);
input.treeGenStatusMsg = "Gespeichert: Models/trees/" + folder + "/" + name + ".j3o";
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Fruchtbusch] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Fruchtbusch Export-Fehler: " + e.getMessage();
}
}
}

View File

@@ -0,0 +1,205 @@
package de.blight.editor.state;
import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.asset.AssetManager;
import com.jme3.bounding.BoundingBox;
import com.jme3.export.binary.BinaryExporter;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector3f;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.texture.Texture;
import de.blight.editor.SharedInput;
import de.blight.editor.tree.GrapevineMeshBuilder;
import de.blight.editor.tree.GrapevineOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class GrapevineGeneratorState extends BaseAppState {
private static final Logger log = LoggerFactory.getLogger(GrapevineGeneratorState.class);
private static final Path ASSET_ROOT = de.blight.editor.ProjectRoot.resolve(
"blight-assets", "src", "main", "resources");
private final SharedInput input;
private SimpleApplication app;
private AssetManager assets;
private TreeGeneratorState previewHost;
public GrapevineGeneratorState(SharedInput input) { this.input = input; }
@Override
protected void initialize(Application app) {
this.app = (SimpleApplication) app;
this.assets = app.getAssetManager();
}
@Override protected void cleanup(Application app) {}
@Override protected void onEnable() {}
@Override protected void onDisable() {}
@Override
public void update(float tpf) {
if (previewHost == null) {
previewHost = getStateManager().getState(TreeGeneratorState.class);
if (previewHost == null) return;
}
SharedInput.GrapevineGenRequest req = input.grapevineGenQueue.poll();
if (req == null) return;
GrapevineOptions opts = req.options();
GrapevineMeshBuilder.MeshResult result = GrapevineMeshBuilder.build(opts);
Node vine = assembleVineNode(result, opts);
vine.updateGeometricState();
BoundingBox bb = vine.getWorldBound() instanceof BoundingBox b ? b : null;
float dist = bb != null
? Math.max(bb.getXExtent(), Math.max(bb.getYExtent(), bb.getZExtent())) * 3.0f
: 5f;
Vector3f target = bb != null
? new Vector3f(0f, bb.getCenter().y, 0f)
: new Vector3f(0f, 1.4f, 0f);
final Node finalVine = vine;
final float finalDist = dist;
final Vector3f finalTarget = target;
final boolean doExport = req.exportAfter();
app.enqueue(() -> {
previewHost.setPreviewContent(finalVine, finalDist, finalTarget);
if (doExport) {
exportVine(finalVine);
}
});
input.treeGenStatusMsg = doExport ? "Weinpflanze: exportiere…" : "Weinpflanze: Vorschau";
}
private Node assembleVineNode(GrapevineMeshBuilder.MeshResult result, GrapevineOptions opts) {
Node node = new Node("weinpflanze");
Geometry bark = new Geometry("bark", result.bark());
bark.setMaterial(buildBarkMat(opts));
bark.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
node.attachChild(bark);
if (result.wires().getVertexCount() > 0) {
Geometry wires = new Geometry("wires", result.wires());
wires.setMaterial(buildWireMat());
wires.setShadowMode(RenderQueue.ShadowMode.Off);
node.attachChild(wires);
}
if (result.leaves().getVertexCount() > 0) {
Geometry leaves = new Geometry("leaves", result.leaves());
leaves.setMaterial(buildLeafMat(opts));
leaves.setQueueBucket(RenderQueue.Bucket.Transparent);
leaves.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
node.attachChild(leaves);
}
if (result.fruits().getVertexCount() > 0) {
Geometry fruits = new Geometry("grapes", result.fruits());
fruits.setMaterial(buildGrapeMat(opts));
fruits.setQueueBucket(RenderQueue.Bucket.Transparent);
fruits.setShadowMode(RenderQueue.ShadowMode.Off);
node.attachChild(fruits);
}
return node;
}
private Material buildBarkMat(GrapevineOptions opts) {
try {
Material mat = new Material(assets, "MatDefs/Tree.j3md");
mat.setColor("Diffuse", new ColorRGBA(0.65f, 0.52f, 0.38f, 1f));
mat.setFloat("WindStrength", opts.windStrength * 0.3f);
mat.setFloat("WindSpeed", opts.windSpeed);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
try {
Texture barkTex = assets.loadTexture("Textures/internal/bark/Bark001_Color.jpg");
barkTex.setWrap(Texture.WrapMode.Repeat);
mat.setTexture("BarkMap", barkTex);
mat.setBoolean("HasBarkMap", true);
} catch (Exception ignored) {}
return mat;
} catch (Exception e) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.45f, 0.32f, 0.18f, 1f));
return mat;
}
}
private Material buildWireMat() {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.22f, 0.20f, 0.18f, 1f));
return mat;
}
private Material buildLeafMat(GrapevineOptions opts) {
try {
Material mat = new Material(assets, "MatDefs/TreeLeaf.j3md");
mat.setColor("Diffuse", new ColorRGBA(opts.leafR, opts.leafG, opts.leafB, 1f));
mat.setFloat("WindStrength", opts.windStrength);
mat.setFloat("WindSpeed", opts.windSpeed);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
try {
mat.setTexture("LeafMap", assets.loadTexture(opts.leafTexture));
mat.setBoolean("HasLeafMap", true);
} catch (Exception ignored) {}
return mat;
} catch (Exception e) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(opts.leafR, opts.leafG, opts.leafB, 1f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
}
private Material buildGrapeMat(GrapevineOptions opts) {
Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md");
mat.setColor("Color", new ColorRGBA(0.42f, 0.18f, 0.55f, 1f));
if (opts.grapeTexture != null) {
try {
Texture tex = assets.loadTexture(opts.grapeTexture);
tex.setWrap(Texture.WrapMode.Clamp);
mat.setTexture("ColorMap", tex);
} catch (Exception ignored) {}
}
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
return mat;
}
private void exportVine(Node vine) {
try {
String ts = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").format(LocalDateTime.now());
String name = "grapevine_" + ts;
Path dir = ASSET_ROOT.resolve("Models").resolve("trees").resolve("grapevine");
Files.createDirectories(dir);
Path out = dir.resolve(name + ".j3o");
BinaryExporter.getInstance().save(vine, out.toFile());
log.info("[Grapevine] Gespeichert: {}", out);
input.treeGenStatusMsg = "Gespeichert: Models/trees/grapevine/" + name + ".j3o";
input.refreshAssets = true;
} catch (IOException e) {
log.error("[Grapevine] Export-Fehler: {}", e.getMessage());
input.treeGenStatusMsg = "Grapevine Export-Fehler: " + e.getMessage();
}
}
}

View File

@@ -443,11 +443,12 @@ public class ModelImportState extends BaseAppState {
// nach einem Editor-Neustart oder im Spiel (dort kein Cache vorhanden).
assets.deleteFromCache(new com.jme3.asset.ModelKey(relPath));
// Zwischendatei löschen wenn der Nutzer einen anderen Dateinamen gewählt hat.
// SceneObjectState hat die Quelldatei unter dem originalen Dateinamen als .j3o
// gespeichert; erst performExport() bäckt Scale + Pivot in die Ziel-Datei.
String intermediateRel = input.modelEditorOpenPath != null
? input.modelEditorOpenPath.replace('\\', '/') : null;
// Zwischendatei immer löschen — sie dient nur der Vorschau und soll nicht
// im Asset-Baum bleiben. modelImportIntermediatePath wird in activateModelImport
// gesetzt und nicht von ModelEditorState geleert (im Gegensatz zu modelEditorOpenPath).
String intermediateRel = input.modelImportIntermediatePath != null
? input.modelImportIntermediatePath.replace('\\', '/') : null;
input.modelImportIntermediatePath = null;
if (intermediateRel != null && !intermediateRel.equals(relPath)) {
Path intermediateAbs = ASSET_ROOT.resolve(intermediateRel);
try {
@@ -459,6 +460,8 @@ public class ModelImportState extends BaseAppState {
}
}
// refreshAssets erst NACH dem Löschen der Zwischendatei setzen,
// damit der Asset-Baum nur die finale Datei anzeigt.
input.modelImportExportStatus = "Gespeichert: " + relPath;
input.refreshAssets = true;

View File

@@ -404,6 +404,9 @@ public class PalmGeneratorState extends BaseAppState {
mat.setColor("Diffuse", new ColorRGBA(opts.barkR, opts.barkG, opts.barkB, 1f));
mat.setFloat("WindStrength", 0.08f);
mat.setFloat("WindSpeed", 0.4f);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
if (opts.barkTexture != null) {
try {
Texture barkTex = assets.loadTexture(opts.barkTexture);
@@ -426,6 +429,9 @@ public class PalmGeneratorState extends BaseAppState {
mat.setColor("Diffuse", new ColorRGBA(opts.leafR, opts.leafG, opts.leafB, 1f));
mat.setFloat("WindStrength", 0.20f);
mat.setFloat("WindSpeed", 0.5f);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
if (opts.leafTexture != null) {
try {
@@ -449,6 +455,9 @@ public class PalmGeneratorState extends BaseAppState {
mat.setColor("Diffuse", color);
mat.setFloat("WindStrength", 0.04f);
mat.setFloat("WindSpeed", 0.3f);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
if (opts.crownTexture != null) {
try {
Texture tex = assets.loadTexture(opts.crownTexture);

View File

@@ -1408,45 +1408,80 @@ public class TerrainEditorState extends BaseAppState {
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal());
int mode = input.heightTool.mode.getSelectedIndex();
// Plateau-RMB: vor dem Terrain-Raycast prüfen, damit Kliff-Flächen funktionieren
if (mode == HeightTool.MODE_PLATEAU && edit.action() < 0) {
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
Vector3f contact = hits.size() > 0 ? hits.getClosestCollision().getContactPoint() : null;
sampleAndSetPlateauHeight(ray, contact);
continue;
}
CollisionResults hits = new CollisionResults();
terrain.collideWith(ray, hits);
if (hits.size() == 0) continue;
Vector3f contact = hits.getClosestCollision().getContactPoint();
int mode = input.heightTool.mode.getSelectedIndex();
boolean terrainChanged = true;
if (mode == HeightTool.MODE_SMOOTH) {
smoothHeight(contact);
} else if (mode == HeightTool.MODE_PLATEAU) {
if (edit.action() < 0) {
// Rechtsklick: Terrain- und Voxel-Höhe sampeln, Maximum als Plateau-Ziel
float h = sampleTerrainHeight(contact);
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
float vh = ves.columnTopWorldY(contact.x, contact.z);
if (Float.isFinite(vh)) h = Float.isFinite(h) ? Math.max(h, vh) : vh;
}
if (Float.isFinite(h)) {
input.heightTool.plateauHeight.setValue(h);
input.heightTool.plateauHeightChanged = true;
}
terrainChanged = false;
if (edit.action() > 0) {
slopeHeight(contact);
} else {
// Linksklick: Terrain schrittweise auf Plateau-Höhe angleichen
flattenToPlateauHeight(contact);
smoothHeight(contact);
}
} else if (mode == HeightTool.MODE_PLATEAU) {
flattenToPlateauHeight(contact);
} else {
float delta = (float) input.heightTool.brushStrength.getValue() * edit.action();
modifyHeight(contact, delta, mode);
}
if (terrainChanged) {
float br = (float) input.heightTool.brushRadius.getValue();
input.terrainEditedAreas.offer(new float[]{contact.x, contact.z, br});
}
float br = (float) input.heightTool.brushRadius.getValue();
input.terrainEditedAreas.offer(new float[]{contact.x, contact.z, br});
}
if (processed > 0) terrain.updateModelBound();
}
private void sampleAndSetPlateauHeight(com.jme3.math.Ray ray, Vector3f terrainContact) {
float h = Float.NaN;
if (terrainContact != null) {
h = sampleTerrainHeight(terrainContact);
}
VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class);
if (ves != null) {
// 1. Versuch: Voxel-Geometrie direkt raycasten (Kliff-Fläche)
Vector3f voxelContact = ves.raycastVoxelGeometry(ray);
if (voxelContact != null) {
float vh = ves.columnTopWorldY(voxelContact.x, voxelContact.z);
if (Float.isFinite(vh)) h = Float.isFinite(h) ? Math.max(h, vh) : vh;
}
// 2. Fallback: XZ aus Terrain-Kontakt
if (terrainContact != null) {
float vh = ves.columnTopWorldY(terrainContact.x, terrainContact.z);
if (Float.isFinite(vh)) h = Float.isFinite(h) ? Math.max(h, vh) : vh;
}
// 3. Fallback: Ray auf y=0 Ebene projizieren → columnTopWorldY findet die Voxel-Säule
if (!Float.isFinite(h) || voxelContact == null) {
float dy = ray.getDirection().y;
if (Math.abs(dy) > 0.001f) {
float t = -ray.getOrigin().y / dy;
if (t > 0.5f) {
float fx = ray.getOrigin().x + t * ray.getDirection().x;
float fz = ray.getOrigin().z + t * ray.getDirection().z;
float vh = ves.columnTopWorldY(fx, fz);
if (Float.isFinite(vh)) h = Float.isFinite(h) ? Math.max(h, vh) : vh;
}
}
}
}
if (Float.isFinite(h)) {
input.heightTool.plateauHeight.setValue(h);
input.heightTool.plateauHeightChanged = true;
input.voxelTool.plateauTarget.setValue(h);
input.voxelTool.plateauTargetChanged = true;
}
}
// ── Höhen-Werkzeug ────────────────────────────────────────────────────────
private void modifyHeight(Vector3f worldContact, float delta, int mode) {
@@ -1552,6 +1587,102 @@ public class TerrainEditorState extends BaseAppState {
if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius);
}
private void slopeHeight(Vector3f worldContact) {
float radius = (float) input.heightTool.brushRadius.getValue();
float strength = (float) input.heightTool.brushStrength.getValue();
int r = (int) Math.ceil(radius / VERTEX_SPACING);
int cx = Math.round((worldContact.x + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
int cz = Math.round((worldContact.z + TERRAIN_SIZE * 0.5f) / VERTEX_SPACING);
if (cachedHeightMap == null) return;
float[] hmap = cachedHeightMap;
float outerMin = 0.7f * radius;
// 1. Höchsten Punkt auf dem äußeren Ring suchen
float maxH = Float.NEGATIVE_INFINITY;
float maxWX = 0, maxWZ = 0;
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist < outerMin || dist >= radius) continue;
float h = hmap[vz * TOTAL_SIZE + vx];
if (!Float.isFinite(h)) continue;
if (h > maxH) { maxH = h; maxWX = dx * VERTEX_SPACING; maxWZ = dz * VERTEX_SPACING; }
}
}
if (!Float.isFinite(maxH)) return;
// 2. Richtung vom Zentrum zum Hochpunkt → Slope-Achse
float highDist = FastMath.sqrt(maxWX * maxWX + maxWZ * maxWZ);
if (highDist < 0.001f) return;
float dirX = maxWX / highDist;
float dirZ = maxWZ / highDist;
// 3. Gegenüberliegenden Punkt finden: Außenring-Vertex mit kleinstem Proj-Wert (= entgegengesetzt zu dir)
float oppH = Float.NaN;
float oppWX = -maxWX, oppWZ = -maxWZ; // Initiales Ideal-Gegenteil
float minProj = Float.MAX_VALUE;
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist < outerMin || dist >= radius) continue;
float h = hmap[vz * TOTAL_SIZE + vx];
if (!Float.isFinite(h)) continue;
float wx = dx * VERTEX_SPACING, wz = dz * VERTEX_SPACING;
float proj = wx * dirX + wz * dirZ;
if (proj < minProj) { minProj = proj; oppH = h; oppWX = wx; oppWZ = wz; }
}
}
if (!Float.isFinite(oppH)) return;
// 4. Projektionsachse: von Gegenpunkt (t=0) zu Hochpunkt (t=1)
float projHigh = maxWX * dirX + maxWZ * dirZ;
float projOpp = oppWX * dirX + oppWZ * dirZ;
float projRange = projHigh - projOpp;
if (Math.abs(projRange) < 0.001f) return;
// 5. Alle Brush-Vertices zur Ziel-Ebene hinziehen
List<Vector2f> locs = new ArrayList<>();
List<Float> deltas = new ArrayList<>();
for (int dz = -r; dz <= r; dz++) {
for (int dx = -r; dx <= r; dx++) {
int vx = cx + dx, vz = cz + dz;
if (vx < 0 || vx >= TOTAL_SIZE || vz < 0 || vz >= TOTAL_SIZE) continue;
float dist = FastMath.sqrt(dx * dx + dz * dz) * VERTEX_SPACING;
if (dist >= radius) continue;
float curH = hmap[vz * TOTAL_SIZE + vx];
if (!Float.isFinite(curH)) continue;
float proj = (dx * VERTEX_SPACING) * dirX + (dz * VERTEX_SPACING) * dirZ;
float t = FastMath.clamp((proj - projOpp) / projRange, 0f, 1f);
float target = oppH + t * (maxH - oppH);
float falloff = (1f + FastMath.cos(FastMath.PI * dist / radius)) * 0.5f;
float blend = FastMath.clamp(falloff * (strength / 50f), 0f, 1f);
float delta = (target - curH) * blend;
if (delta == 0f) continue;
locs.add(new Vector2f(vx * VERTEX_SPACING - TERRAIN_SIZE * 0.5f,
vz * VERTEX_SPACING - TERRAIN_SIZE * 0.5f));
deltas.add(delta);
}
}
if (!locs.isEmpty()) {
terrain.adjustHeight(locs, deltas);
syncHeightCache(locs, deltas);
if (placedObjectState != null) placedObjectState.adjustObjectHeights(locs, deltas);
if (sceneObjState != null) sceneObjState.snapToTerrain(worldContact.x, worldContact.z, radius);
if (grassVertexState != null) grassVertexState.adjustBladeHeights(worldContact, radius);
}
}
/** Liest die Terrain-Höhe am nächstgelegenen Vertex zum Kontaktpunkt. */
public float sampleTerrainHeight(Vector3f worldContact) {
if (cachedHeightMap == null) return Float.NaN;

View File

@@ -35,8 +35,6 @@ import com.jme3.scene.Spatial;
import com.jme3.scene.VertexBuffer;
import com.jme3.scene.control.AbstractControl;
import com.jme3.scene.shape.Sphere;
import com.jme3.shadow.DirectionalLightShadowRenderer;
import com.jme3.shadow.EdgeFilteringMode;
import com.jme3.texture.FrameBuffer;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
@@ -138,13 +136,13 @@ public class TreeGeneratorState extends BaseAppState {
previewScene.attachChild(buildPreviewGrid());
previewVP.attachScene(previewScene);
DirectionalLightShadowRenderer shadowRenderer =
new DirectionalLightShadowRenderer(assets, 2048, 1);
shadowRenderer.setLight(previewSunLight);
shadowRenderer.setEdgeFilteringMode(EdgeFilteringMode.PCF4);
shadowRenderer.setShadowIntensity(0.25f);
shadowRenderer.setShadowZExtend(80f);
previewVP.addProcessor(shadowRenderer);
// Shadow-Renderer absichtlich NICHT im Preview-Viewport:
// Tree.j3md / TreeLeaf.j3md haben eine PostShadow-Technik mit Blend Modulate.
// Wird ShadowIntensity nicht korrekt auf 0.25 propagiert (JME3-Bug mit
// Custom-PostShadow + PCF4), bleibt der j3md-Default 1.0 aktiv →
// lit = 0 → Blend Modulate → komplettes Schwarz.
// Die Preview-Beleuchtung kommt ohnehin aus den Custom-Uniforms
// (LightDir / SunColor / AmbientColor) und braucht keinen Shadow-Renderer.
// Stellt sicher, dass previewScene direkt vor dem Rendern immer aktuell ist
// unabhängig davon, welche AppStates nach TreeGeneratorState die Szene noch ändern.
previewVP.addProcessor(new SceneProcessor() {
@@ -169,7 +167,10 @@ public class TreeGeneratorState extends BaseAppState {
public void setPreviewContent(com.jme3.scene.Node node, float camDist,
com.jme3.math.Vector3f target) {
previewTreeHolder.detachAllChildren();
if (node != null) previewTreeHolder.attachChild(node);
if (node != null) {
ImpostorUtil.applyTreeLighting(node, previewSunLight.getDirection().negate());
previewTreeHolder.attachChild(node);
}
this.previewCamDist = camDist;
this.previewTarget.set(target);
}
@@ -315,6 +316,7 @@ public class TreeGeneratorState extends BaseAppState {
Node previewTree = makeTreeNode(pendingHdResult,
pendingBarkMat.clone(), pendingLeafMat.clone(), "prev");
ImpostorUtil.applyTreeLighting(previewTree, previewSunLight.getDirection().negate());
previewTreeHolder.detachAllChildren();
previewTreeHolder.attachChild(previewTree);
@@ -380,6 +382,9 @@ public class TreeGeneratorState extends BaseAppState {
mat.setColor("Diffuse", new ColorRGBA(0.42f, 0.26f, 0.10f, 1f));
mat.setFloat("WindStrength", 0.15f);
mat.setFloat("WindSpeed", 0.5f);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
if (p.barkTexture != null) {
try {
Texture barkTex = assets.loadTexture(p.barkTexture);
@@ -404,6 +409,9 @@ public class TreeGeneratorState extends BaseAppState {
mat.setColor("Diffuse", new ColorRGBA(0.18f, 0.60f, 0.10f, 1f));
mat.setFloat("WindStrength", 0.30f);
mat.setFloat("WindSpeed", 0.7f);
mat.setVector3("LightDir", new Vector3f(0.45f, 1.0f, 0.3f).normalizeLocal());
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
mat.getAdditionalRenderState().setFaceCullMode(RenderState.FaceCullMode.Off);
mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
if (p.leafTexture != null) {

View File

@@ -23,7 +23,10 @@ import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import com.jme3.scene.VertexBuffer;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Image;
import com.jme3.texture.Texture;
import com.jme3.texture.Texture2D;
import com.jme3.texture.image.ColorSpace;
import com.jme3.util.BufferUtils;
import de.blight.common.VoxelChunk;
import de.blight.common.VoxelChunkIO;
@@ -35,11 +38,18 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.jme3.export.binary.BinaryExporter;
import java.io.IOException;
import de.blight.common.MapData;
import de.blight.common.MapIO;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import java.util.*;
import java.util.concurrent.*;
import java.util.ArrayDeque;
@@ -145,6 +155,19 @@ public class VoxelEditorState extends BaseAppState {
/** Vorheriger Layer-Zustand für Einstieg/Ausstieg-Erkennung. */
private boolean prevLayerWasVoxel = false;
// ── Voxel-Splatmap ───────────────────────────────────────────────────────
private static final int VOX_SPLAT_SIZE = MapData.SPLAT_SIZE; // 2049
private static final float VOX_WORLD_HALF = 2048f;
private static final float VOX_SPLAT_WE_PER_PX =
(VOX_WORLD_HALF * 2f) / (VOX_SPLAT_SIZE - 1); // ~2 WE/px
private byte[] voxSplatR, voxSplatG, voxSplatB, voxSplatA;
private ByteBuffer voxSplatBuf;
private Image voxSplatImage;
private Texture2D voxSplatTex;
private boolean splatDirty = false;
// ── LOD-Rebuild-Queue ─────────────────────────────────────────────────────
/** Chunks, die LOD1/2 neu brauchen. Wird im Hintergrund-Thread abgearbeitet. */
@@ -188,6 +211,7 @@ public class VoxelEditorState extends BaseAppState {
}
voxelMaterial = buildMaterial();
initVoxelSplat();
brushIndicator = buildBrushIndicator();
app.getRootNode().attachChild(brushIndicator);
@@ -289,11 +313,16 @@ public class VoxelEditorState extends BaseAppState {
clearMarkedOverlays();
}
// Voxel-Texturen aktualisiert?
// Voxel-Texturen (Slot/Normal-Map) aktualisiert?
if (input.voxelTexturesChanged) {
input.voxelTexturesChanged = false;
applyTextures(voxelMaterial);
}
// Voxel-Splat-Texturen (TexFlat2/3/4) aktualisiert?
if (input.voxelSplatTexturesChanged) {
input.voxelSplatTexturesChanged = false;
applyVoxelSplatTextures(voxelMaterial);
}
// Layer-Wechsel erkennen → Referenzebene steuern (kein automatischer Wireframe-Wechsel)
boolean isVoxelLayer = input.activeLayer == SharedInput.LAYER_VOXEL;
@@ -307,6 +336,14 @@ public class VoxelEditorState extends BaseAppState {
applyWireframe(input.voxelWireframeEnabled);
}
// Voxel-Textur-Malen: parallel zum TerrainEditorState, wenn der Textur-Layer aktiv ist
if (input.activeLayer == 4) {
processVoxelTextureEdits();
idleSinceSave += tpf;
checkAutoSave();
return;
}
// Nur aktiv wenn LAYER_VOXEL gesetzt
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
idleSinceEdit = 0f;
@@ -603,6 +640,17 @@ public class VoxelEditorState extends BaseAppState {
bestPos = cr.getContactPoint();
bestNorm = new Vector3f(0, 1, 0);
}
results.clear();
}
}
// Fallback auf Basis-Terrain wenn noch keine Voxel vorhanden sind,
// damit Pinsel-Indikator und erster Pinsel-Tick korrekt auf dem Terrain landen.
if (bestPos == null && terrainNode != null) {
terrainNode.collideWith(ray, results);
if (results.size() > 0) {
bestPos = results.getClosestCollision().getContactPoint();
bestNorm = new Vector3f(0, 1, 0);
}
}
@@ -657,6 +705,8 @@ public class VoxelEditorState extends BaseAppState {
if (Float.isFinite(h)) {
input.voxelTool.plateauTarget.setValue(h);
input.voxelTool.plateauTargetChanged = true;
input.heightTool.plateauHeight.setValue(h);
input.heightTool.plateauHeightChanged = true;
}
return;
}
@@ -722,9 +772,11 @@ public class VoxelEditorState extends BaseAppState {
applyColumnToTarget(chunk, cx, cy, cz, wx, wz, radius, strength, coord -> target);
} else if (modeIdx == de.blight.editor.tool.VoxelTool.MODE_SMOOTH) {
if (lower) {
applyCliffColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
} else {
// RMB: Durchschnitt-Smooth (wie vormals LMB)
applySmoothColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
} else {
// LMB: gleichmäßiger Slope vom höchsten Außenring-Punkt zum Gegenpunkt
applySlopeColumn(chunk, cx, cy, cz, wx, wz, radius, strength, slopeParams);
}
} else {
applyColumnBrush(chunk, cx, cy, cz, wx, wz, radius, strength, modeIdx, lower);
@@ -1065,40 +1117,79 @@ public class VoxelEditorState extends BaseAppState {
* projLow = Projektion des Tiefpunkts auf die Achse (immer ≤ projHigh)
* avgH = Durchschnittshöhe aller Spalten im Pinselbereich (für Smooth-Modus)
*/
/**
* Slope-Parameter für den Smooth-LMB-Modus.
* Rückgabe: [0]=maxH, [1]=oppH, [2]=dirX, [3]=dirZ, [4]=projHigh, [5]=projOpp, [6]=avgH
* Achse zeigt vom Zentrum zum höchsten Außenring-Punkt; oppH ist der geometrisch
* gegenüberliegende Außenring-Punkt (kleinste Projektion auf diese Achse).
*/
private float[] computeSlopeParams(float brushWX, float brushWZ, float radius) {
float r2 = radius * radius;
float r2 = radius * radius;
float outerMin = 0.7f * radius;
float outerMin2 = outerMin * outerMin;
int xMin = (int)(brushWX - radius), xMax = (int) Math.ceil(brushWX + radius);
int zMin = (int)(brushWZ - radius), zMax = (int) Math.ceil(brushWZ + radius);
float maxH = Float.NEGATIVE_INFINITY, minH = Float.POSITIVE_INFINITY;
float maxX = brushWX, maxZ = brushWZ, minX = brushWX, minZ = brushWZ;
float sum = 0f;
// Höchsten Außenring-Punkt finden + Durchschnitt über den gesamten Pinsel
float maxH = Float.NEGATIVE_INFINITY;
float maxOX = 0, maxOZ = 0;
float sum = 0f;
int count = 0;
for (int xi = xMin; xi <= xMax; xi++) {
float dx = xi - brushWX;
for (int zi = zMin; zi <= zMax; zi++) {
float dz = zi - brushWZ;
if (dx*dx + dz*dz > r2) continue;
float d2 = dx*dx + dz*dz;
if (d2 > r2) continue;
float h = columnTopWorldY(xi, zi);
if (h > maxH) { maxH = h; maxX = xi; maxZ = zi; }
if (h < minH) { minH = h; minX = xi; minZ = zi; }
sum += h;
count++;
if (d2 >= outerMin2 && h > maxH) { maxH = h; maxOX = dx; maxOZ = dz; }
}
}
if (maxH == Float.NEGATIVE_INFINITY) return new float[]{ Float.NaN, Float.NaN, 0, 0, 0, 0, Float.NaN };
if (maxH == Float.NEGATIVE_INFINITY || count == 0)
return new float[]{ Float.NaN, Float.NaN, 0, 0, 0, 0, Float.NaN };
// Richtung vom tiefsten zum höchsten Punkt — garantiert projHigh - projLow = |maxPos - minPos|
float ddx = maxX - minX, ddz = maxZ - minZ;
float len = (float) Math.sqrt(ddx*ddx + ddz*ddz);
if (len < 1e-4f) { ddx = 1f; ddz = 0f; } else { ddx /= len; ddz /= len; }
// Achse: Zentrum → Hochpunkt
float highDist = (float) Math.sqrt(maxOX*maxOX + maxOZ*maxOZ);
float dirX, dirZ;
if (highDist < 1e-4f) { dirX = 1f; dirZ = 0f; }
else { dirX = maxOX / highDist; dirZ = maxOZ / highDist; }
float projHigh = (maxX - brushWX) * ddx + (maxZ - brushWZ) * ddz;
float projLow = (minX - brushWX) * ddx + (minZ - brushWZ) * ddz;
// Gegenüberliegender Punkt: Außenring-Vertex mit kleinstem Projektons-Wert
float oppH = Float.NaN;
float oppOX = 0, oppOZ = 0;
float minProj = Float.MAX_VALUE;
for (int xi = xMin; xi <= xMax; xi++) {
float dx = xi - brushWX;
for (int zi = zMin; zi <= zMax; zi++) {
float dz = zi - brushWZ;
float d2 = dx*dx + dz*dz;
if (d2 < outerMin2 || d2 > r2) continue;
float proj = dx * dirX + dz * dirZ;
if (proj < minProj) {
minProj = proj;
oppH = columnTopWorldY(xi, zi);
oppOX = dx; oppOZ = dz;
}
}
}
if (Float.isNaN(oppH)) oppH = sum / count;
float projHigh = maxOX * dirX + maxOZ * dirZ;
float projOpp = oppOX * dirX + oppOZ * dirZ;
float avgH = sum / count;
return new float[]{ maxH, minH, ddx, ddz, projHigh, projLow, avgH };
return new float[]{ maxH, oppH, dirX, dirZ, projHigh, projOpp, avgH };
}
/** Raycast gegen reine Voxel-Geometrie; gibt nächsten Treffpunkt oder null zurück. */
public Vector3f raycastVoxelGeometry(com.jme3.math.Ray ray) {
CollisionResults results = new CollisionResults();
voxelRoot.collideWith(ray, results);
if (results.size() == 0) return null;
return results.getClosestCollision().getContactPoint();
}
/** Welt-Y des höchsten Solid-Voxels an (worldX, worldZ), oder Terrain-Höhe wenn keine Voxel. */
@@ -1251,27 +1342,22 @@ public class VoxelEditorState extends BaseAppState {
}
/**
* Klippe-Pinsel (Smooth-Rechtsklick): scharfe Terrassenstufe mittig zwischen Hoch- und Tiefpunkt.
* Slope-Pinsel (Smooth-Linksklick): gleichmäßige Neigung vom höchsten Außenring-Punkt
* zum geometrisch gegenüberliegenden Punkt.
*/
private void applyCliffColumn(VoxelChunk chunk, int cx, int cy, int cz,
private void applySlopeColumn(VoxelChunk chunk, int cx, int cy, int cz,
float brushWX, float brushWZ,
float radius, float strength, float[] sp) {
if (sp == null || Float.isNaN(sp[0])) return;
final float highH = sp[0], lowH = sp[1], dirX = sp[2], dirZ = sp[3];
final float projHigh = sp[4], projLow = sp[5];
final float projRange = projHigh - projLow;
if (sp == null || Float.isNaN(sp[0]) || Float.isNaN(sp[1])) return;
final float highH = sp[0], oppH = sp[1], dirX = sp[2], dirZ = sp[3];
final float projHigh = sp[4], projOpp = sp[5];
final float projRange = projHigh - projOpp;
if (projRange < 0.5f) return;
final float projMid = (projHigh + projLow) * 0.5f;
final float blendHalf = Math.max(0.3f, projRange * 0.1f);
applyColumnToTarget(chunk, cx, cy, cz, brushWX, brushWZ, radius, strength, coord -> {
float proj = coord[0] * dirX + coord[1] * dirZ;
float rel = proj - projMid;
if (rel > blendHalf) return highH;
if (rel < -blendHalf) return lowH;
float t = (rel / blendHalf + 1f) * 0.5f;
return lowH + (highH - lowH) * t;
float t = Math.max(0f, Math.min(1f, (proj - projOpp) / projRange));
return oppH + t * (highH - oppH);
});
}
@@ -1744,11 +1830,18 @@ public class VoxelEditorState extends BaseAppState {
private void checkAutoSave() {
if (idleSinceSave < AUTO_SAVE_IDLE_S) return;
boolean anyDirty = false;
boolean anyDirty = splatDirty;
for (VoxelChunk c : chunks.values()) { if (c.dirty) { anyDirty = true; break; } }
if (!anyDirty) return;
idleSinceSave = 0f;
final boolean saveSplat = splatDirty;
if (saveSplat) splatDirty = false;
final byte[] snapR = saveSplat ? voxSplatR.clone() : null;
final byte[] snapG = saveSplat ? voxSplatG.clone() : null;
final byte[] snapB = saveSplat ? voxSplatB.clone() : null;
final byte[] snapA = saveSplat ? voxSplatA.clone() : null;
executor.submit(() -> {
for (VoxelChunk chunk : chunks.values()) {
if (!chunk.dirty) continue;
@@ -1759,9 +1852,212 @@ public class VoxelEditorState extends BaseAppState {
chunk.cx, chunk.cy, chunk.cz, e.getMessage());
}
}
if (saveSplat) {
try {
saveVoxelSplat(snapR, snapG, snapB, snapA);
} catch (IOException e) {
log.error("Auto-Save Voxel-Splatmap: {}", e.getMessage());
}
}
});
}
// ── Voxel-Splatmap: Init / Speichern / Laden ─────────────────────────────
private void initVoxelSplat() {
voxSplatR = new byte[VOX_SPLAT_SIZE * VOX_SPLAT_SIZE];
voxSplatG = new byte[VOX_SPLAT_SIZE * VOX_SPLAT_SIZE];
voxSplatB = new byte[VOX_SPLAT_SIZE * VOX_SPLAT_SIZE];
voxSplatA = new byte[VOX_SPLAT_SIZE * VOX_SPLAT_SIZE];
Arrays.fill(voxSplatR, (byte) 255);
// Vorhandene Datei laden (falls vorhanden)
Path file = voxelSplatFile();
if (Files.exists(file)) {
try {
loadVoxelSplat(file);
} catch (IOException e) {
log.warn("Voxel-Splatmap nicht ladbar, wird neu initialisiert: {}", e.getMessage());
Arrays.fill(voxSplatR, (byte) 255);
Arrays.fill(voxSplatG, (byte) 0);
Arrays.fill(voxSplatB, (byte) 0);
Arrays.fill(voxSplatA, (byte) 0);
}
}
voxSplatBuf = BufferUtils.createByteBuffer(VOX_SPLAT_SIZE * VOX_SPLAT_SIZE * 4);
for (int i = 0; i < VOX_SPLAT_SIZE * VOX_SPLAT_SIZE; i++) {
voxSplatBuf.put(voxSplatR[i]).put(voxSplatG[i]).put(voxSplatB[i]).put(voxSplatA[i]);
}
voxSplatBuf.flip();
voxSplatImage = new Image(Image.Format.RGBA8, VOX_SPLAT_SIZE, VOX_SPLAT_SIZE,
voxSplatBuf, ColorSpace.Linear);
voxSplatTex = new Texture2D(voxSplatImage);
voxelMaterial.setTexture("SplatMap", voxSplatTex);
applyVoxelSplatTextures(voxelMaterial);
}
private Path voxelSplatFile() {
return MapIO.getMapPath().resolveSibling("blight_voxel_splat.bin");
}
private void loadVoxelSplat(Path file) throws IOException {
try (DataInputStream in = new DataInputStream(
new BufferedInputStream(new GZIPInputStream(Files.newInputStream(file))))) {
in.readFully(voxSplatR);
in.readFully(voxSplatG);
in.readFully(voxSplatB);
in.readFully(voxSplatA);
// Lade Texturpfade falls vorhanden
try {
String[] paths = new String[4];
for (int i = 0; i < 4; i++) paths[i] = in.readUTF();
input.voxelSplatTexturePaths = paths;
} catch (EOFException ignored) {}
}
}
private void saveVoxelSplat(byte[] snapR, byte[] snapG, byte[] snapB, byte[] snapA)
throws IOException {
Path file = voxelSplatFile();
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
Files.createDirectories(file.getParent());
try (DataOutputStream out = new DataOutputStream(
new BufferedOutputStream(new GZIPOutputStream(Files.newOutputStream(tmp))))) {
out.write(snapR);
out.write(snapG);
out.write(snapB);
out.write(snapA);
String[] paths = input.voxelSplatTexturePaths;
for (int i = 0; i < 4; i++) {
String p = (paths != null && i < paths.length && paths[i] != null) ? paths[i] : "";
out.writeUTF(p);
}
}
try {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException e) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
log.info("Voxel-Splatmap gespeichert: {}", file);
}
// ── Voxel-Textur-Malen ────────────────────────────────────────────────────
private void processVoxelTextureEdits() {
SharedInput.TextureEdit edit;
int processed = 0;
while ((edit = input.voxelTextureEditQueue.poll()) != null && processed < MAX_EDITS_PER_FRAME) {
processed++;
float jmeX = (float)(edit.screenX() * input.viewportScaleX);
float jmeY = cam.getHeight() - (float)(edit.screenY() * input.viewportScaleY);
Vector3f near = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 0f);
Vector3f far = cam.getWorldCoordinates(new Vector2f(jmeX, jmeY), 1f);
com.jme3.math.Ray ray = new com.jme3.math.Ray(near, far.subtract(near).normalizeLocal());
// Raycast gegen Voxel-Geometrie
CollisionResults hits = new CollisionResults();
voxelRoot.collideWith(ray, hits);
Vector3f contact = null;
if (hits.size() > 0) {
contact = hits.getClosestCollision().getContactPoint();
}
// Fallback auf Basis-Terrain
if (contact == null && terrainNode != null) {
CollisionResults terrHits = new CollisionResults();
terrainNode.collideWith(ray, terrHits);
if (terrHits.size() > 0) contact = terrHits.getClosestCollision().getContactPoint();
}
if (contact == null) continue;
int selIdx = input.textureTool.textureIndex.getSelectedIndex();
float str = (float) input.textureTool.brushStrength.getValue();
if (edit.action() > 0) {
applyVoxelTexturePaint(contact, str, selIdx);
} else {
applyVoxelTexturePaint(contact, str, 0);
}
}
if (processed > 0) {
idleSinceSave = 0f;
}
}
private void applyVoxelTexturePaint(Vector3f contact, float strength, int textureIndex) {
float radius = (float) input.textureTool.brushRadius.getValue();
int centerPX = Math.round((contact.x + VOX_WORLD_HALF) / VOX_SPLAT_WE_PER_PX);
int centerPZ = Math.round((contact.z + VOX_WORLD_HALF) / VOX_SPLAT_WE_PER_PX);
int pixR = (int) Math.ceil(radius / VOX_SPLAT_WE_PER_PX);
boolean changed = false;
for (int dz = -pixR; dz <= pixR; dz++) {
int pz = centerPZ + dz;
if (pz < 0 || pz >= VOX_SPLAT_SIZE) continue;
for (int dx = -pixR; dx <= pixR; dx++) {
int px = centerPX + dx;
if (px < 0 || px >= VOX_SPLAT_SIZE) continue;
float distWE = FastMath.sqrt(dx * dx + dz * dz) * VOX_SPLAT_WE_PER_PX;
if (distWE >= radius) continue;
float t = distWE / radius;
float falloff = (1f + FastMath.cos(FastMath.PI * t)) * 0.5f;
float blend = (textureIndex == 0) ? falloff : strength * falloff;
int idx = pz * VOX_SPLAT_SIZE + px;
float curG = (voxSplatG[idx] & 0xFF) / 255f;
float curB = (voxSplatB[idx] & 0xFF) / 255f;
float curA = (voxSplatA[idx] & 0xFF) / 255f;
float tgG = (textureIndex == 1) ? 1f : 0f;
float tgB = (textureIndex == 2) ? 1f : 0f;
float tgA = (textureIndex == 3) ? 1f : 0f;
voxSplatR[idx] = (byte) 255;
voxSplatG[idx] = (byte) Math.round((curG + (tgG - curG) * blend) * 255f);
voxSplatB[idx] = (byte) Math.round((curB + (tgB - curB) * blend) * 255f);
voxSplatA[idx] = (byte) Math.round((curA + (tgA - curA) * blend) * 255f);
int bi = idx * 4;
voxSplatBuf.put(bi, voxSplatR[idx]);
voxSplatBuf.put(bi + 1, voxSplatG[idx]);
voxSplatBuf.put(bi + 2, voxSplatB[idx]);
voxSplatBuf.put(bi + 3, voxSplatA[idx]);
changed = true;
}
}
if (changed) {
voxSplatBuf.rewind();
voxSplatImage.setUpdateNeeded();
splatDirty = true;
}
}
private void applyVoxelSplatTextures(Material mat) {
String[] paths = input.voxelSplatTexturePaths;
String[] slots = {"TexFlat2", "TexFlat3", "TexFlat4"};
int[][] fallbacks = {{100, 130, 60}, {110, 100, 90}, {80, 80, 100}};
for (int i = 0; i < 3; i++) {
String p = (paths != null && (i + 1) < paths.length) ? paths[i + 1] : "";
if (p != null && !p.isEmpty()) {
try {
Texture t = assets.loadTexture(p);
t.getImage().setColorSpace(ColorSpace.Linear);
t.setWrap(Texture.WrapMode.Repeat);
mat.setTexture(slots[i], t);
} catch (Exception e) {
log.warn("Voxel-Splat-Textur {} nicht ladbar: {}", p, e.getMessage());
mat.setTexture(slots[i], solidColorTexture(fallbacks[i]));
}
} else {
mat.setTexture(slots[i], solidColorTexture(fallbacks[i]));
}
}
}
// ── Intern: Material ─────────────────────────────────────────────────────
private Material buildMaterial() {
@@ -1877,7 +2173,9 @@ public class VoxelEditorState extends BaseAppState {
private void updateBrushIndicator() {
if (brushIndicator == null) return;
if (input.activeLayer != SharedInput.LAYER_VOXEL) {
boolean isVoxelOrTex = input.activeLayer == SharedInput.LAYER_VOXEL
|| input.activeLayer == 4;
if (!isVoxelOrTex) {
brushIndicator.setCullHint(Spatial.CullHint.Always);
return;
}
@@ -1891,7 +2189,9 @@ public class VoxelEditorState extends BaseAppState {
float jmeY = cam.getHeight() - my * (float) input.viewportScaleY;
Hit hit = raycastHit(jmeX, jmeY);
if (hit != null) {
float r = (float) input.voxelTool.brushRadius.getValue();
float r = (input.activeLayer == 4)
? (float) input.textureTool.brushRadius.getValue()
: (float) input.voxelTool.brushRadius.getValue();
// Leicht entlang der Flächen-Normalen versetzt, um Z-Fighting zu vermeiden
brushIndicator.setLocalTranslation(hit.pos.add(hit.normal().mult(0.05f)));

View File

@@ -0,0 +1,321 @@
package de.blight.editor.tree;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.scene.Geometry;
import com.jme3.scene.Mesh;
import com.jme3.scene.Node;
import com.jme3.scene.VertexBuffer;
import com.jme3.util.BufferUtils;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* Prozeduraler Fruchtbusch-Generator.
*
* Erzeugt drei Kinder-Nodes:
* "bark" — Stamm + Äste als Zylinder-Geometrie
* "leaves" — Blatt-Quads (Cross-Billboard) an den Astspitzen
* "fruits" — kleine Kugel-Approx. (2 gekreuzte Quads) an Blattclustern
*
* Vertex-Farbe R = Wind-Gewicht (0 = Stammbasis, 1 = Astspitze).
*/
public class FruitBushMeshBuilder {
public record MeshResult(Mesh bark, Mesh leaves, Mesh fruits) {}
public static MeshResult build(FruitBushOptions o) {
Random rng = new Random(o.seed);
VertexCollector barkCol = new VertexCollector();
VertexCollector leafCol = new VertexCollector();
VertexCollector fruitCol = new VertexCollector();
// ── Stamm ────────────────────────────────────────────────────────────
int trunkSegs = 7;
Vector3f trunkBot = new Vector3f(0f, 0f, 0f);
Vector3f trunkTop = new Vector3f(0f, o.trunkHeight, 0f);
addCylinder(barkCol, trunkBot, trunkTop, o.trunkRadius, o.trunkRadius * 0.70f,
0f, 0.15f, trunkSegs);
// ── Primäräste ───────────────────────────────────────────────────────
float branchAngleRad = o.branchAngle * FastMath.DEG_TO_RAD;
float twigAngleRad = o.twigAngle * FastMath.DEG_TO_RAD;
for (int b = 0; b < o.branchCount; b++) {
float azimuth = b * FastMath.TWO_PI / o.branchCount
+ rng.nextFloat() * 0.4f - 0.2f;
float lenScale = 0.80f + rng.nextFloat() * 0.40f;
float bLen = o.branchLength * lenScale;
// Richtung: um branchAngle° geneigt, azimuth° um Y gedreht
Vector3f bDir = branchDir(Vector3f.UNIT_Y, azimuth, branchAngleRad);
// Startet etwas über dem Stammende mit leichter Versetzung
float startT = 0.6f + rng.nextFloat() * 0.35f;
Vector3f bBase = trunkTop.mult(startT);
Vector3f bTip = bBase.add(bDir.mult(bLen));
float branchBaseRad = o.trunkRadius * 0.45f;
float windBase = 0.15f + startT * 0.20f;
float windTip = 0.60f;
addCylinder(barkCol, bBase, bTip, branchBaseRad, branchBaseRad * 0.22f,
windBase, windTip, 5);
// ── Sekundäräste (Reiser) ─────────────────────────────────────
for (int t = 0; t < o.twigCount; t++) {
float tAz = t * FastMath.TWO_PI / o.twigCount
+ rng.nextFloat() * 0.5f - 0.25f;
float tLenScl = 0.75f + rng.nextFloat() * 0.50f;
float tLen = o.twigLength * tLenScl;
Vector3f tDir = branchDir(bDir, tAz, twigAngleRad);
float tStart = 0.35f + rng.nextFloat() * 0.50f;
Vector3f tBase = bBase.add(bDir.mult(bLen * tStart));
Vector3f tTip = tBase.add(tDir.mult(tLen));
float windTBase = windBase + (windTip - windBase) * tStart;
addCylinder(barkCol, tBase, tTip, o.twigRadius, o.twigRadius * 0.10f,
windTBase, 0.90f, 4);
// Blätter entlang des gesamten Reisers alle ~12 cm ein Cluster
int twigClusters = Math.max(2, Math.round(tLen / 0.12f));
int leafsPerCluster = Math.max(3, o.leafCount / 2);
for (int lc = 0; lc < twigClusters; lc++) {
float lT = FastMath.clamp(
(lc + 0.5f + rng.nextFloat() * 0.4f - 0.2f) / twigClusters,
0.05f, 1.0f);
Vector3f lPos = tBase.add(tDir.mult(tLen * lT));
float wL = windTBase + (0.90f - windTBase) * lT;
addLeafCluster(leafCol, lPos, wL, o.leafSize, leafsPerCluster, rng);
}
// Früchte an der Astgabel (Gabelpunkt Primärast → Reiser)
if (o.fruitsEnabled && rng.nextFloat() < o.fruitDensity) {
addFruitSphere(fruitCol, tBase, windTBase, o.fruitRadius, rng);
}
}
// Blätter auch entlang des Primärasts (zwischen den Gabeln)
int branchClusters = Math.max(1, Math.round(bLen / 0.22f));
for (int lc = 0; lc < branchClusters; lc++) {
float lT = (lc + 0.5f + rng.nextFloat() * 0.3f - 0.15f) / branchClusters;
lT = FastMath.clamp(lT, 0.05f, 1.0f);
Vector3f lPos = bBase.add(bDir.mult(bLen * lT));
float wL = windBase + (windTip - windBase) * lT;
addLeafCluster(leafCol, lPos, wL, o.leafSize * 0.80f,
Math.max(2, o.leafCount / 3), rng);
}
}
return new MeshResult(barkCol.toMesh(), leafCol.toMesh(), fruitCol.toMesh());
}
// ── Zylinder-Segment ─────────────────────────────────────────────────────
private static void addCylinder(VertexCollector col,
Vector3f start, Vector3f end,
float rBot, float rTop,
float windBot, float windTop, int N) {
Vector3f axis = end.subtract(start);
if (axis.lengthSquared() < 1e-8f) return;
axis.normalizeLocal();
Vector3f perp1 = (Math.abs(axis.y) < 0.9f)
? axis.cross(Vector3f.UNIT_Y).normalizeLocal()
: axis.cross(Vector3f.UNIT_X).normalizeLocal();
Vector3f perp2 = axis.cross(perp1).normalizeLocal();
int base = col.vertexCount;
int N1 = N + 1;
for (int ring = 0; ring < 2; ring++) {
Vector3f center = (ring == 0) ? start : end;
float r = (ring == 0) ? rBot : rTop;
float wind = (ring == 0) ? windBot : windTop;
float vCoord = ring;
for (int i = 0; i <= N; i++) {
float theta = FastMath.TWO_PI * i / N;
float cos = FastMath.cos(theta);
float sin = FastMath.sin(theta);
float nx = cos * perp1.x + sin * perp2.x;
float ny = cos * perp1.y + sin * perp2.y;
float nz = cos * perp1.z + sin * perp2.z;
col.add(center.x + nx * r, center.y + ny * r, center.z + nz * r,
nx, ny, nz, (float) i / N, vCoord, wind);
}
}
for (int i = 0; i < N; i++) {
int b0 = base + i, b1 = base + i + 1;
int t0 = base + N1 + i, t1 = base + N1 + i + 1;
col.tri(b0, b1, t1);
col.tri(b0, t1, t0);
}
}
// ── Blatt-Cluster ────────────────────────────────────────────────────────
private static void addLeafCluster(VertexCollector col, Vector3f tip,
float wind, float scale, int count, Random rng) {
for (int i = 0; i < count; i++) {
float ox = rng.nextFloat() * scale * 0.20f - scale * 0.10f;
float oy = rng.nextFloat() * scale * 0.10f - scale * 0.05f;
float oz = rng.nextFloat() * scale * 0.20f - scale * 0.10f;
float s = scale * (0.70f + rng.nextFloat() * 0.60f);
// Volle 3D-Richtung: zufälliger Azimuth + zufällige Neigung (volle Kugel)
float yaw = rng.nextFloat() * FastMath.TWO_PI;
float pitch = rng.nextFloat() * FastMath.PI;
float sinP = FastMath.sin(pitch);
float gx = sinP * FastMath.cos(yaw);
float gy = FastMath.cos(pitch);
float gz = sinP * FastMath.sin(yaw);
addLeafQuad(col, tip.x + ox, tip.y + oy, tip.z + oz, s, wind, gx, gy, gz);
}
}
private static void addLeafQuad(VertexCollector col,
float cx, float cy, float cz,
float s, float wind,
float gx, float gy, float gz) {
// Rechtsvektor senkrecht zur Wachstumsrichtung
float rx, ry, rz;
if (Math.abs(gy) < 0.95f) {
rx = gz; ry = 0f; rz = -gx; // cross(UNIT_Y, grow)
} else {
rx = 0f; ry = -gz; rz = gy; // cross(UNIT_X, grow)
}
float rLen = FastMath.sqrt(rx * rx + ry * ry + rz * rz);
if (rLen > 1e-5f) { rx /= rLen; ry /= rLen; rz /= rLen; }
float hw = s * 0.5f;
float hh = s * 1.3f;
// Normalvektor: right × grow
float nx = ry * gz - rz * gy;
float ny = rz * gx - rx * gz;
float nz = rx * gy - ry * gx;
float windTip = Math.min(1.0f, wind + 0.25f);
int base = col.vertexCount;
// Untere Kante am Ast (verankert)
col.add(cx - rx * hw, cy - ry * hw, cz - rz * hw,
nx, ny, nz, 0f, 0f, wind);
col.add(cx + rx * hw, cy + ry * hw, cz + rz * hw,
nx, ny, nz, 1f, 0f, wind);
// Obere Kante frei (schwingt im Wind)
col.add(cx + rx * hw + gx * hh, cy + ry * hw + gy * hh, cz + rz * hw + gz * hh,
nx, ny, nz, 1f, 1f, windTip);
col.add(cx - rx * hw + gx * hh, cy - ry * hw + gy * hh, cz - rz * hw + gz * hh,
nx, ny, nz, 0f, 1f, windTip);
col.tri(base, base + 1, base + 2);
col.tri(base, base + 2, base + 3);
col.tri(base + 2, base + 1, base);
col.tri(base + 3, base + 2, base);
}
// ── Frucht-Kugel (2 gekreuzte Quads) ─────────────────────────────────────
private static void addFruitSphere(VertexCollector col, Vector3f tip,
float wind, float radius, Random rng) {
float ox = rng.nextFloat() * radius * 0.8f - radius * 0.4f;
// Frucht hängt unterhalb der Gabel: Zentrum ca. 1.8× Radius unter Gabelposition
float oy = -radius * 1.8f + rng.nextFloat() * radius * 0.4f - radius * 0.2f;
float oz = rng.nextFloat() * radius * 0.8f - radius * 0.4f;
float cx = tip.x + ox, cy = tip.y + oy, cz = tip.z + oz;
addFruitQuad(col, cx, cy, cz, radius, wind, 0f);
addFruitQuad(col, cx, cy, cz, radius, wind, FastMath.HALF_PI);
}
private static void addFruitQuad(VertexCollector col,
float cx, float cy, float cz,
float r, float wind, float yaw) {
float cosY = FastMath.cos(yaw), sinY = FastMath.sin(yaw);
float[] xs = { cx - cosY * r, cx + cosY * r, cx + cosY * r, cx - cosY * r };
float[] ys = { cy - r, cy - r, cy + r, cy + r };
float[] zs = { cz + sinY * r, cz - sinY * r, cz - sinY * r, cz + sinY * r };
float nnx = sinY, nnz = cosY;
int base = col.vertexCount;
col.add(xs[0], ys[0], zs[0], nnx, 0, nnz, 0, 0, wind);
col.add(xs[1], ys[1], zs[1], nnx, 0, nnz, 1, 0, wind);
col.add(xs[2], ys[2], zs[2], nnx, 0, nnz, 1, 1, wind);
col.add(xs[3], ys[3], zs[3], nnx, 0, nnz, 0, 1, wind);
col.tri(base, base+1, base+2);
col.tri(base, base+2, base+3);
col.tri(base+2, base+1, base);
col.tri(base+3, base+2, base);
}
// ── Ast-Richtung ─────────────────────────────────────────────────────────
private static Vector3f branchDir(Vector3f parent, float yaw, float tiltRad) {
Vector3f perp = (Math.abs(parent.y) < 0.9f)
? parent.cross(Vector3f.UNIT_Y).normalizeLocal()
: parent.cross(Vector3f.UNIT_X).normalizeLocal();
Quaternion tilt = new Quaternion().fromAngleAxis(tiltRad, perp);
Quaternion spin = new Quaternion().fromAngleAxis(yaw, parent);
return spin.mult(tilt.mult(parent)).normalizeLocal();
}
// ── Vertex-Sammler ────────────────────────────────────────────────────────
private static final class VertexCollector {
final List<Float> pos = new ArrayList<>();
final List<Float> norm = new ArrayList<>();
final List<Float> uv = new ArrayList<>();
final List<Float> wind = new ArrayList<>();
final List<Integer> idx = new ArrayList<>();
int vertexCount = 0;
void add(float x, float y, float z,
float nx, float ny, float nz,
float u, float v, float w) {
pos.add(x); pos.add(y); pos.add(z);
norm.add(nx); norm.add(ny); norm.add(nz);
uv.add(u); uv.add(v);
wind.add(w);
vertexCount++;
}
void tri(int a, int b, int c) { idx.add(a); idx.add(b); idx.add(c); }
Mesh toMesh() {
if (vertexCount == 0) return new Mesh();
int n = vertexCount;
FloatBuffer posB = BufferUtils.createFloatBuffer(n * 3);
FloatBuffer normB = BufferUtils.createFloatBuffer(n * 3);
FloatBuffer uvB = BufferUtils.createFloatBuffer(n * 2);
FloatBuffer colB = BufferUtils.createFloatBuffer(n * 4);
IntBuffer idxB = BufferUtils.createIntBuffer(idx.size());
for (Float f : pos) posB.put(f);
for (Float f : norm) normB.put(f);
for (Float f : uv) uvB.put(f);
for (Float f : wind) { colB.put(f); colB.put(0f); colB.put(0f); colB.put(1f); }
for (Integer i : idx) idxB.put(i);
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, posB);
mesh.setBuffer(VertexBuffer.Type.Normal, 3, normB);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uvB);
mesh.setBuffer(VertexBuffer.Type.Color, 4, colB);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idxB);
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
}
}

View File

@@ -0,0 +1,145 @@
package de.blight.editor.tree;
/** Parameter für den prozeduralen Fruchtbusch-Generator. */
public class FruitBushOptions {
public int seed = 55123;
/** Höhe des Stamms in Metern. */
public float trunkHeight = 1.20f;
/** Radius des Stamms an der Basis. */
public float trunkRadius = 0.10f;
/** Anzahl Primäräste (direkt vom Stamm). */
public int branchCount = 5;
/** Winkel der Primäräste zur Vertikalen (Grad). */
public float branchAngle = 50f;
/** Länge der Primäräste (m). */
public float branchLength = 1.60f;
/** Anzahl Sekundäräste pro Primärast. */
public int twigCount = 4;
/** Winkel der Sekundäräste zur Elternrichtung (Grad). */
public float twigAngle = 45f;
/** Länge der Sekundäräste (m). */
public float twigLength = 0.90f;
/** Radius der Endäste. */
public float twigRadius = 0.024f;
/** Blatt-Größe (Quadrat-Seite, m). */
public float leafSize = 0.44f;
/** Blätter pro Sekundärast. */
public int leafCount = 16;
/** Blatt-Textur (relativ zum Asset-Root). */
public String leafTexture = "Textures/internal/foliage/fruittree.png";
/** Blatt-Tint R. */
public float leafR = 0.50f, leafG = 0.80f, leafB = 0.22f;
/** Früchte aktivieren. */
public boolean fruitsEnabled = true;
/** Wahrscheinlichkeit [01], dass ein Blattcluster eine Frucht bekommt. */
public float fruitDensity = 0.35f;
/** Frucht-Radius (m). */
public float fruitRadius = 0.08f;
/** Frucht-Farbe R. */
public float fruitR = 1.0f, fruitG = 0.45f, fruitB = 0.0f;
/** Frucht-Textur (relativ zum Asset-Root, RGBA mit Alpha-Maske). */
public String fruitTexture = "Textures/internal/fruits/orange.png";
public float windStrength = 0.20f;
public float windSpeed = 0.60f;
// ── Presets ──────────────────────────────────────────────────────────────
public static FruitBushOptions orange() {
FruitBushOptions o = new FruitBushOptions();
o.seed = 55123;
o.trunkHeight = 1.30f;
o.trunkRadius = 0.11f;
o.branchCount = 5;
o.branchAngle = 50f;
o.branchLength = 1.80f;
o.twigCount = 4;
o.twigAngle = 44f;
o.twigLength = 1.00f;
o.twigRadius = 0.026f;
o.leafSize = 0.48f;
o.leafCount = 18;
o.leafR = 0.50f; o.leafG = 0.82f; o.leafB = 0.22f;
o.fruitsEnabled = true;
o.fruitDensity = 0.32f;
o.fruitRadius = 0.08f;
o.fruitR = 1.00f; o.fruitG = 0.45f; o.fruitB = 0.00f;
o.fruitTexture = "Textures/internal/fruits/orange.png";
return o;
}
public static FruitBushOptions lemon() {
FruitBushOptions o = new FruitBushOptions();
o.seed = 63241;
o.trunkHeight = 1.10f;
o.trunkRadius = 0.096f;
o.branchCount = 6;
o.branchAngle = 46f;
o.branchLength = 1.50f;
o.twigCount = 3;
o.twigAngle = 40f;
o.twigLength = 0.84f;
o.twigRadius = 0.024f;
o.leafSize = 0.44f;
o.leafCount = 16;
o.leafR = 0.48f; o.leafG = 0.80f; o.leafB = 0.25f;
o.fruitsEnabled = true;
o.fruitDensity = 0.30f;
o.fruitRadius = 0.08f;
o.fruitR = 0.92f; o.fruitG = 0.88f; o.fruitB = 0.12f;
o.fruitTexture = "Textures/internal/fruits/lemon.png";
return o;
}
public static FruitBushOptions apricot() {
FruitBushOptions o = new FruitBushOptions();
o.seed = 47892;
o.trunkHeight = 1.00f;
o.trunkRadius = 0.084f;
o.branchCount = 4;
o.branchAngle = 54f;
o.branchLength = 1.40f;
o.twigCount = 4;
o.twigAngle = 48f;
o.twigLength = 0.80f;
o.twigRadius = 0.022f;
o.leafSize = 0.40f;
o.leafCount = 16;
o.leafR = 0.52f; o.leafG = 0.78f; o.leafB = 0.20f;
o.fruitsEnabled = true;
o.fruitDensity = 0.68f;
o.fruitRadius = 0.04f;
o.fruitR = 0.95f; o.fruitG = 0.62f; o.fruitB = 0.24f;
o.fruitTexture = "Textures/internal/fruits/apricot.png";
return o;
}
public FruitBushOptions copy() {
FruitBushOptions c = new FruitBushOptions();
c.seed = seed;
c.trunkHeight = trunkHeight;
c.trunkRadius = trunkRadius;
c.branchCount = branchCount;
c.branchAngle = branchAngle;
c.branchLength = branchLength;
c.twigCount = twigCount;
c.twigAngle = twigAngle;
c.twigLength = twigLength;
c.twigRadius = twigRadius;
c.leafSize = leafSize;
c.leafCount = leafCount;
c.leafTexture = leafTexture;
c.leafR = leafR; c.leafG = leafG; c.leafB = leafB;
c.fruitsEnabled = fruitsEnabled;
c.fruitDensity = fruitDensity;
c.fruitRadius = fruitRadius;
c.fruitR = fruitR; c.fruitG = fruitG; c.fruitB = fruitB;
c.fruitTexture = fruitTexture;
c.windStrength = windStrength;
c.windSpeed = windSpeed;
return c;
}
}

View File

@@ -0,0 +1,421 @@
package de.blight.editor.tree;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.scene.Mesh;
import com.jme3.scene.VertexBuffer;
import com.jme3.util.BufferUtils;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
public class GrapevineMeshBuilder {
public record MeshResult(Mesh bark, Mesh wires, Mesh leaves, Mesh fruits) {}
public static MeshResult build(GrapevineOptions o) {
Random rng = new Random(o.seed);
float halfW = o.totalWidth * 0.5f;
VertexCollector barkCol = new VertexCollector();
VertexCollector wireCol = new VertexCollector();
VertexCollector leafCol = new VertexCollector();
VertexCollector fruitCol = new VertexCollector();
// ── Pfosten ───────────────────────────────────────────────────────────
addCylinder(barkCol,
new Vector3f(-halfW, 0, 0), new Vector3f(-halfW, o.totalHeight + 0.15f, 0),
o.postRadius, o.postRadius * 0.80f, 0f, 0f, 7);
addCylinder(barkCol,
new Vector3f(halfW, 0, 0), new Vector3f(halfW, o.totalHeight + 0.15f, 0),
o.postRadius, o.postRadius * 0.80f, 0f, 0f, 7);
// ── Drähte ────────────────────────────────────────────────────────────
float wireR = 0.006f;
for (int w = 0; w < o.wireCount; w++) {
float wh = o.firstWireH
+ (o.totalHeight - o.firstWireH) * (float) w / Math.max(1, o.wireCount - 1);
addCylinder(wireCol,
new Vector3f(-halfW, wh, 0), new Vector3f(halfW, wh, 0),
wireR, wireR, 0f, 0f, 4);
}
// ── Stamm (gewunden) ──────────────────────────────────────────────────
addBentPath(barkCol,
new Vector3f(0, 0, 0), new Vector3f(0, o.firstWireH, 0),
o.trunkRadius, o.trunkRadius * 0.70f, 0f, 0f,
4, 6, 0.10f, rng);
// ── Trieb-t-Werte vorab berechnen → Armlänge = Position des letzten Triebs
float[] leftTs = new float[o.shootCount];
float[] rightTs = new float[o.shootCount];
float leftMaxT = 0f, rightMaxT = 0f;
for (int s = 0; s < o.shootCount; s++) {
leftTs[s] = FastMath.clamp((s + 0.5f + rng.nextFloat() * 0.5f - 0.25f) / o.shootCount, 0.08f, 0.97f);
rightTs[s] = FastMath.clamp((s + 0.5f + rng.nextFloat() * 0.5f - 0.25f) / o.shootCount, 0.08f, 0.97f);
leftMaxT = Math.max(leftMaxT, leftTs[s]);
rightMaxT = Math.max(rightMaxT, rightTs[s]);
}
// ── Arme (enden genau beim letzten Trieb) ────────────────────────────
Vector3f armCenter = new Vector3f(0, o.firstWireH, 0);
float leftArmEnd = halfW * leftMaxT;
float rightArmEnd = halfW * rightMaxT;
Vector3f[] leftArmPts = bentPathPoints(armCenter, new Vector3f(-leftArmEnd, o.firstWireH, 0), 5, 0.07f, rng);
Vector3f[] rightArmPts = bentPathPoints(armCenter, new Vector3f( rightArmEnd, o.firstWireH, 0), 5, 0.07f, rng);
addPolylineCylinder(barkCol, leftArmPts, o.trunkRadius * 0.65f, o.trunkRadius * 0.38f, 0f, 0f, 5);
addPolylineCylinder(barkCol, rightArmPts, o.trunkRadius * 0.65f, o.trunkRadius * 0.38f, 0f, 0f, 5);
// ── Triebe + Blätter + Trauben ────────────────────────────────────────
float shootH = o.totalHeight - o.firstWireH;
for (int side = -1; side <= 1; side += 2) {
float[] ts = (side == -1) ? leftTs : rightTs;
float maxT = (side == -1) ? leftMaxT : rightMaxT;
Vector3f[] armPts = (side == -1) ? leftArmPts : rightArmPts;
for (int s = 0; s < o.shootCount; s++) {
float t = ts[s];
// Startpunkt exakt auf dem gebogenen Arm (t/maxT → 0..1 entlang des Pfads)
Vector3f sBase = interpPath(armPts, t / maxT);
float tiltX = rng.nextFloat() * 0.12f - 0.06f;
float tiltZ = rng.nextFloat() * 0.12f - 0.06f;
Vector3f sDir = new Vector3f(tiltX, 1f, tiltZ).normalizeLocal();
Vector3f sTip = sBase.add(sDir.mult(shootH));
Vector3f[] shootPts = bentPathPoints(sBase, sTip, 3, 0.12f, rng);
addPolylineCylinder(barkCol, shootPts,
o.shootRadius, o.shootRadius * 0.20f, 0f, 0f, 4);
// Blätter im oberen 65%
int clusters = Math.max(2, o.leafCount / 2);
for (int lc = 0; lc < clusters; lc++) {
float lt = FastMath.clamp(
0.35f + (lc + 0.5f + rng.nextFloat() * 0.3f - 0.15f) / clusters * 0.65f,
0.30f, 1.0f);
Vector3f lp = interpPath(shootPts, lt);
float wl = 0.55f + 0.45f * lt;
addLeafCluster(leafCol, lp, wl, o.leafSize,
Math.max(2, o.leafCount / clusters), rng);
}
// 2 Trauben immer, 3. mit 50 % Wahrscheinlichkeit weiter oben.
// Unterste Traube: Oberkante auf Höhe des niedersten Blattes (t≈0.35).
int grapeCount = rng.nextFloat() < 0.50f ? 3 : 2;
float[] grapeTs = new float[]{ 0.33f + rng.nextFloat() * 0.06f, // 0.330.39
0.48f + rng.nextFloat() * 0.10f, // 0.480.58
0.65f + rng.nextFloat() * 0.12f }; // 0.650.77
for (int g = 0; g < grapeCount; g++) {
Vector3f ga = interpPath(shootPts, grapeTs[g]);
addGrapeBunch(fruitCol, ga.x, ga.y, ga.z, 0.12f, 0.25f);
}
}
}
return new MeshResult(barkCol.toMesh(), wireCol.toMesh(), leafCol.toMesh(), fruitCol.toMesh());
}
// ── Blatt-Cluster / Quad ──────────────────────────────────────────────────
private static void addLeafCluster(VertexCollector col, Vector3f tip,
float wind, float scale, int count, Random rng) {
for (int i = 0; i < count; i++) {
float ox = rng.nextFloat() * scale * 0.20f - scale * 0.10f;
float oy = rng.nextFloat() * scale * 0.10f - scale * 0.05f;
float oz = rng.nextFloat() * scale * 0.20f - scale * 0.10f;
float s = scale * (0.70f + rng.nextFloat() * 0.60f);
float yaw = rng.nextFloat() * FastMath.TWO_PI;
float pitch = rng.nextFloat() * FastMath.PI;
float sinP = FastMath.sin(pitch);
float gx = sinP * FastMath.cos(yaw);
float gy = FastMath.cos(pitch);
float gz = sinP * FastMath.sin(yaw);
addLeafQuad(col, tip.x + ox, tip.y + oy, tip.z + oz, s, wind, gx, gy, gz);
}
}
private static void addLeafQuad(VertexCollector col,
float cx, float cy, float cz,
float s, float wind,
float gx, float gy, float gz) {
float rx, ry, rz;
if (Math.abs(gy) < 0.95f) {
rx = gz; ry = 0f; rz = -gx;
} else {
rx = 0f; ry = -gz; rz = gy;
}
float rLen = FastMath.sqrt(rx * rx + ry * ry + rz * rz);
if (rLen > 1e-5f) { rx /= rLen; ry /= rLen; rz /= rLen; }
float hw = s * 0.5f;
float hh = s * 1.3f;
float nx = ry * gz - rz * gy;
float ny = rz * gx - rx * gz;
float nz = rx * gy - ry * gx;
float windTip = Math.min(1.0f, wind + 0.25f);
int base = col.vertexCount;
col.add(cx - rx * hw, cy - ry * hw, cz - rz * hw, nx, ny, nz, 0f, 0f, wind);
col.add(cx + rx * hw, cy + ry * hw, cz + rz * hw, nx, ny, nz, 1f, 0f, wind);
col.add(cx + rx * hw + gx * hh, cy + ry * hw + gy * hh, cz + rz * hw + gz * hh, nx, ny, nz, 1f, 1f, windTip);
col.add(cx - rx * hw + gx * hh, cy - ry * hw + gy * hh, cz - rz * hw + gz * hh, nx, ny, nz, 0f, 1f, windTip);
col.tri(base, base + 1, base + 2);
col.tri(base, base + 2, base + 3);
col.tri(base + 2, base + 1, base);
col.tri(base + 3, base + 2, base);
}
// ── Trauben-Billboard ─────────────────────────────────────────────────────
/** Zwei gekreuzte Quads für eine ganze Traube (hängt nach unten vom Aufhängepunkt). */
private static void addGrapeBunch(VertexCollector col,
float cx, float cy, float cz,
float w, float h) {
// Quad 1 — X/Y-Ebene
int base = col.vertexCount;
col.add(cx - w, cy, cz, 0, 0, 1, 0, 1, 0f);
col.add(cx + w, cy, cz, 0, 0, 1, 1, 1, 0f);
col.add(cx + w, cy - h, cz, 0, 0, 1, 1, 0, 0f);
col.add(cx - w, cy - h, cz, 0, 0, 1, 0, 0, 0f);
col.tri(base, base+1, base+2); col.tri(base, base+2, base+3);
col.tri(base+2, base+1, base); col.tri(base+3, base+2, base);
// Quad 2 — Z/Y-Ebene
base = col.vertexCount;
col.add(cx, cy, cz - w, 1, 0, 0, 0, 1, 0f);
col.add(cx, cy, cz + w, 1, 0, 0, 1, 1, 0f);
col.add(cx, cy - h, cz + w, 1, 0, 0, 1, 0, 0f);
col.add(cx, cy - h, cz - w, 1, 0, 0, 0, 0, 0f);
col.tri(base, base+1, base+2); col.tri(base, base+2, base+3);
col.tri(base+2, base+1, base); col.tri(base+3, base+2, base);
}
// ── Zylinder ──────────────────────────────────────────────────────────────
private static void addCylinder(VertexCollector col,
Vector3f start, Vector3f end,
float rBot, float rTop,
float windBot, float windTop, int N) {
Vector3f axis = end.subtract(start);
if (axis.lengthSquared() < 1e-8f) return;
axis.normalizeLocal();
Vector3f perp1 = (Math.abs(axis.y) < 0.9f)
? axis.cross(Vector3f.UNIT_Y).normalizeLocal()
: axis.cross(Vector3f.UNIT_X).normalizeLocal();
Vector3f perp2 = axis.cross(perp1).normalizeLocal();
int base = col.vertexCount;
int N1 = N + 1;
for (int ring = 0; ring < 2; ring++) {
Vector3f center = (ring == 0) ? start : end;
float r = (ring == 0) ? rBot : rTop;
float wind = (ring == 0) ? windBot : windTop;
float vCoord = ring;
for (int i = 0; i <= N; i++) {
float theta = FastMath.TWO_PI * i / N;
float cos = FastMath.cos(theta);
float sin = FastMath.sin(theta);
float nx = cos * perp1.x + sin * perp2.x;
float ny = cos * perp1.y + sin * perp2.y;
float nz = cos * perp1.z + sin * perp2.z;
col.add(center.x + nx * r, center.y + ny * r, center.z + nz * r,
nx, ny, nz, (float) i / N, vCoord, wind);
}
}
for (int i = 0; i < N; i++) {
int b0 = base + i, b1 = base + i + 1;
int t0 = base + N1 + i, t1 = base + N1 + i + 1;
col.tri(b0, b1, t1);
col.tri(b0, t1, t0);
}
}
// ── Gebogene Pfade ────────────────────────────────────────────────────────
/**
* Berechnet Pfadpunkte von start→end mit zufälligen Versätzen senkrecht zur
* Hauptrichtung. Die Hüllkurve (sin) stellt sicher, dass Anfang und Ende exakt
* auf der Geraden liegen.
*
* @param segments Anzahl Zwischenpunkte + 1 (Segmente); Gesamt-Punkte = segments+1
* @param bendAmt Maximale Auslenkung relativ zur Länge (z.B. 0.10 = 10 %)
*/
private static Vector3f[] bentPathPoints(Vector3f start, Vector3f end,
int segments, float bendAmt, Random rng) {
Vector3f[] pts = new Vector3f[segments + 1];
pts[0] = start.clone();
pts[segments] = end.clone();
Vector3f main = end.subtract(start);
float len = main.length();
if (len < 1e-6f) {
for (int i = 1; i < segments; i++) pts[i] = start.clone();
return pts;
}
main.divideLocal(len);
Vector3f p1 = (Math.abs(main.y) < 0.9f)
? main.cross(Vector3f.UNIT_Y).normalizeLocal()
: main.cross(Vector3f.UNIT_X).normalizeLocal();
Vector3f p2 = main.cross(p1).normalizeLocal();
for (int i = 1; i < segments; i++) {
float t = (float) i / segments;
float envelope = FastMath.sin(t * FastMath.PI);
float off1 = (rng.nextFloat() * 2f - 1f) * bendAmt * len * envelope;
float off2 = (rng.nextFloat() * 2f - 1f) * bendAmt * len * envelope;
pts[i] = start.add(main.mult(len * t))
.addLocal(p1.mult(off1))
.addLocal(p2.mult(off2));
}
return pts;
}
/** Linearer Interpolationspfad entlang eines Punkt-Arrays, t ∈ [0,1]. */
private static Vector3f interpPath(Vector3f[] pts, float t) {
if (pts.length == 1) return pts[0].clone();
float scaled = t * (pts.length - 1);
int lo = (int) scaled;
float frac = scaled - lo;
if (lo >= pts.length - 1) return pts[pts.length - 1].clone();
return pts[lo].clone().interpolateLocal(pts[lo + 1], frac);
}
/** Convenience-Wrapper: berechnet Punkte und rendert nahtlosen Polylinien-Zylinder. */
private static void addBentPath(VertexCollector col,
Vector3f start, Vector3f end,
float rBot, float rTop,
float windBot, float windTop,
int segments, int ringN,
float bendAmt, Random rng) {
Vector3f[] pts = bentPathPoints(start, end, segments, bendAmt, rng);
addPolylineCylinder(col, pts, rBot, rTop, windBot, windTop, ringN);
}
/**
* Erzeugt einen nahtlosen Zylinder entlang beliebig vieler Pfadpunkte.
* Parallel-Transport verhindert Twist und Lücken an den Übergängen.
*/
private static void addPolylineCylinder(VertexCollector col, Vector3f[] pts,
float rBot, float rTop,
float windBot, float windTop, int N) {
int numPts = pts.length;
if (numPts < 2) return;
int N1 = N + 1;
// Tangentenrichtung an jedem Punkt
Vector3f[] tang = new Vector3f[numPts];
for (int p = 0; p < numPts; p++) {
if (p == 0) {
tang[p] = pts[1].subtract(pts[0]).normalizeLocal();
} else if (p == numPts - 1) {
tang[p] = pts[p].subtract(pts[p - 1]).normalizeLocal();
} else {
Vector3f a = pts[p].subtract(pts[p - 1]).normalizeLocal();
Vector3f b = pts[p + 1].subtract(pts[p]).normalizeLocal();
tang[p] = a.add(b).normalizeLocal();
}
}
// Parallel-Transport: perp1 des ersten Rings auf alle weiteren projizieren
Vector3f[] perp1 = new Vector3f[numPts];
perp1[0] = (Math.abs(tang[0].y) < 0.9f)
? tang[0].cross(Vector3f.UNIT_Y).normalizeLocal()
: tang[0].cross(Vector3f.UNIT_X).normalizeLocal();
for (int p = 1; p < numPts; p++) {
float dot = tang[p].dot(perp1[p - 1]);
Vector3f v = perp1[p - 1].subtract(tang[p].mult(dot));
perp1[p] = (v.lengthSquared() > 1e-8f) ? v.normalizeLocal() : perp1[p - 1].clone();
}
int baseVtx = col.vertexCount;
// Ringe erzeugen
for (int p = 0; p < numPts; p++) {
float t = (float) p / (numPts - 1);
float r = rBot + (rTop - rBot) * t;
float wind = windBot + (windTop - windBot) * t;
Vector3f p2 = tang[p].cross(perp1[p]).normalizeLocal();
for (int i = 0; i <= N; i++) {
float theta = FastMath.TWO_PI * i / N;
float cos = FastMath.cos(theta);
float sin = FastMath.sin(theta);
float nx = cos * perp1[p].x + sin * p2.x;
float ny = cos * perp1[p].y + sin * p2.y;
float nz = cos * perp1[p].z + sin * p2.z;
col.add(pts[p].x + nx * r, pts[p].y + ny * r, pts[p].z + nz * r,
nx, ny, nz, (float) i / N, t, wind);
}
}
// Ringe verbinden
for (int p = 0; p < numPts - 1; p++) {
for (int i = 0; i < N; i++) {
int b0 = baseVtx + p * N1 + i;
int b1 = baseVtx + p * N1 + i + 1;
int t0 = baseVtx + (p + 1) * N1 + i;
int t1 = baseVtx + (p + 1) * N1 + i + 1;
col.tri(b0, b1, t1);
col.tri(b0, t1, t0);
}
}
}
// ── Vertex-Sammler ────────────────────────────────────────────────────────
private static final class VertexCollector {
final List<Float> pos = new ArrayList<>();
final List<Float> norm = new ArrayList<>();
final List<Float> uv = new ArrayList<>();
final List<Float> wind = new ArrayList<>();
final List<Integer> idx = new ArrayList<>();
int vertexCount = 0;
void add(float x, float y, float z,
float nx, float ny, float nz,
float u, float v, float w) {
pos.add(x); pos.add(y); pos.add(z);
norm.add(nx); norm.add(ny); norm.add(nz);
uv.add(u); uv.add(v);
wind.add(w);
vertexCount++;
}
void tri(int a, int b, int c) { idx.add(a); idx.add(b); idx.add(c); }
Mesh toMesh() {
if (vertexCount == 0) return new Mesh();
int n = vertexCount;
FloatBuffer posB = BufferUtils.createFloatBuffer(n * 3);
FloatBuffer normB = BufferUtils.createFloatBuffer(n * 3);
FloatBuffer uvB = BufferUtils.createFloatBuffer(n * 2);
FloatBuffer colB = BufferUtils.createFloatBuffer(n * 4);
IntBuffer idxB = BufferUtils.createIntBuffer(idx.size());
for (Float f : pos) posB.put(f);
for (Float f : norm) normB.put(f);
for (Float f : uv) uvB.put(f);
for (Float f : wind) { colB.put(f); colB.put(0f); colB.put(0f); colB.put(1f); }
for (Integer i : idx) idxB.put(i);
Mesh mesh = new Mesh();
mesh.setBuffer(VertexBuffer.Type.Position, 3, posB);
mesh.setBuffer(VertexBuffer.Type.Normal, 3, normB);
mesh.setBuffer(VertexBuffer.Type.TexCoord, 2, uvB);
mesh.setBuffer(VertexBuffer.Type.Color, 4, colB);
mesh.setBuffer(VertexBuffer.Type.Index, 3, idxB);
mesh.updateBound();
mesh.updateCounts();
return mesh;
}
}
}

View File

@@ -0,0 +1,42 @@
package de.blight.editor.tree;
public class GrapevineOptions {
public int seed = 77541;
public float totalWidth = 6.0f; // Pfosten-zu-Pfosten
public float totalHeight = 2.8f;
public float firstWireH = 0.90f; // Stammhöhe = erster Draht
public int wireCount = 4;
public float postRadius = 0.065f;
public float trunkRadius = 0.040f;
public float shootRadius = 0.016f;
public int shootCount = 7; // pro Arm
public float leafSize = 0.30f;
public int leafCount = 96;
public String leafTexture = "Textures/internal/foliage/wine.png";
public float leafR = 0.35f, leafG = 0.72f, leafB = 0.18f;
public String grapeTexture = "Textures/internal/fruits/wine.png";
public float windStrength = 0.12f;
public float windSpeed = 0.45f;
public GrapevineOptions copy() {
GrapevineOptions c = new GrapevineOptions();
c.seed = seed;
c.totalWidth = totalWidth;
c.totalHeight = totalHeight;
c.firstWireH = firstWireH;
c.wireCount = wireCount;
c.postRadius = postRadius;
c.trunkRadius = trunkRadius;
c.shootRadius = shootRadius;
c.shootCount = shootCount;
c.leafSize = leafSize;
c.leafCount = leafCount;
c.leafTexture = leafTexture;
c.leafR = leafR; c.leafG = leafG; c.leafB = leafB;
c.grapeTexture = grapeTexture;
c.windStrength = windStrength;
c.windSpeed = windSpeed;
return c;
}
}

View File

@@ -27,7 +27,7 @@ public class PalmOptions {
// Textures
public String barkTexture = "Textures/internal/bark/Bark_Palm.png";
public String leafTexture = "Textures/internal/leaves/palm.png";
public String leafTexture = "Textures/internal/foliage/palm.png";
public float leafTextureAspect = 0f; // width/length-Verhältnis der Textur, 0 = frondWidth nutzen
public float gravity = 0.3f; // Durchhang: 0 = gerade, höher = stärker hängend
@@ -35,7 +35,7 @@ public class PalmOptions {
public float crownHeight = 1.2f;
public float crownFlare = 1.6f; // max. Aufweitung = trunkRadiusTop × crownFlare
public float crownR = 0.22f, crownG = 0.58f, crownB = 0.14f;
public String crownTexture = "Textures/internal/leaves/palmcrown.png";
public String crownTexture = "Textures/internal/foliage/palmcrown.png";
public PalmOptions copy() {

View File

@@ -223,47 +223,62 @@ public class TreeMeshBuilder {
float wind, float scale, int count,
float angleDeg, Rng rng) {
for (int i = 0; i < count; i++) {
float ox = rng.range(-scale * 0.5f, scale * 0.5f);
float oy = rng.range(-scale * 0.25f, scale * 0.25f);
float oz = rng.range(-scale * 0.5f, scale * 0.5f);
float ox = rng.range(-scale * 0.20f, scale * 0.20f);
float oy = rng.range(-scale * 0.10f, scale * 0.10f);
float oz = rng.range(-scale * 0.20f, scale * 0.20f);
float s = scale * (0.7f + rng.range(0f, 0.6f));
float tilt = angleDeg * FastMath.DEG_TO_RAD;
addLeafQuad(col, tip.x + ox, tip.y + oy, tip.z + oz,
s, wind, rng.range(0f, FastMath.TWO_PI), tilt);
// Volle 3D-Richtung: zufälliger Azimuth + zufällige Neigung (volle Kugel)
float yaw = rng.range(0f, FastMath.TWO_PI);
float pitch = rng.range(0f, FastMath.PI);
float sinP = FastMath.sin(pitch);
float gx = sinP * FastMath.cos(yaw);
float gy = FastMath.cos(pitch);
float gz = sinP * FastMath.sin(yaw);
addLeafQuad(col, tip.x + ox, tip.y + oy, tip.z + oz, s, wind, gx, gy, gz);
}
}
private static void addLeafQuad(VertexCollector col,
float cx, float cy, float cz,
float s, float wind, float yRot, float tilt) {
float cosY = FastMath.cos(yRot), sinY = FastMath.sin(yRot);
float cosT = FastMath.cos(tilt), sinT = FastMath.sin(tilt);
float hw = s * 0.5f;
float hh = s * 0.65f;
// Quad A
for (int q = 0; q < 2; q++) {
// second quad perpendicular (yRot + PI/2)
float cy2 = (q == 0) ? cosY : -sinY;
float sz2 = (q == 0) ? sinY : cosY;
int base = col.vertexCount;
// 4 Ecken: unten-links, unten-rechts, oben-rechts, oben-links
// "oben" ist um tilt-Grad nach vorne/hinten geneigt
float[] xs = {-hw * cy2, hw * cy2, hw * cy2, -hw * cy2};
float[] zs = {-hw * sz2, hw * sz2, hw * sz2, -hw * sz2};
float[] ys = {-hh * cosT, -hh * cosT, hh * cosT, hh * cosT};
// Face normal = right × up = (cy2,0,sz2) × (0,1,0) = (-sz2, 0, cy2)
float nnx = -sz2, nnz = cy2;
col.add(cx + xs[0], cy + ys[0], cz + zs[0], nnx, 0, nnz, 0, 0, wind);
col.add(cx + xs[1], cy + ys[1], cz + zs[1], nnx, 0, nnz, 1, 0, wind);
col.add(cx + xs[2], cy + ys[2], cz + zs[2], nnx, 0, nnz, 1, 1, wind);
col.add(cx + xs[3], cy + ys[3], cz + zs[3], nnx, 0, nnz, 0, 1, wind);
col.tri(base, base+1, base+2);
col.tri(base, base+2, base+3);
col.tri(base+2, base+1, base); // back face
col.tri(base+3, base+2, base);
float s, float wind,
float gx, float gy, float gz) {
// Rechtsvektor senkrecht zur Wachstumsrichtung
float rx, ry, rz;
if (Math.abs(gy) < 0.95f) {
rx = gz; ry = 0f; rz = -gx; // cross(UNIT_Y, grow)
} else {
rx = 0f; ry = -gz; rz = gy; // cross(UNIT_X, grow)
}
float rLen = FastMath.sqrt(rx * rx + ry * ry + rz * rz);
if (rLen > 1e-5f) { rx /= rLen; ry /= rLen; rz /= rLen; }
float hw = s * 0.5f;
float hh = s * 1.3f;
// Normalvektor: right × grow
float nx = ry * gz - rz * gy;
float ny = rz * gx - rx * gz;
float nz = rx * gy - ry * gx;
float windTip = Math.min(1.0f, wind + 0.25f);
int base = col.vertexCount;
// Untere Kante am Ast (verankert)
col.add(cx - rx * hw, cy - ry * hw, cz - rz * hw,
nx, ny, nz, 0f, 0f, wind);
col.add(cx + rx * hw, cy + ry * hw, cz + rz * hw,
nx, ny, nz, 1f, 0f, wind);
// Obere Kante frei (schwingt im Wind)
col.add(cx + rx * hw + gx * hh, cy + ry * hw + gy * hh, cz + rz * hw + gz * hh,
nx, ny, nz, 1f, 1f, windTip);
col.add(cx - rx * hw + gx * hh, cy - ry * hw + gy * hh, cz - rz * hw + gz * hh,
nx, ny, nz, 0f, 1f, windTip);
col.tri(base, base + 1, base + 2);
col.tri(base, base + 2, base + 3);
col.tri(base + 2, base + 1, base);
col.tri(base + 3, base + 2, base);
}
// ── Ast-Richtung berechnen ────────────────────────────────────────────────

View File

@@ -43,7 +43,7 @@ public class TreeParams {
// ── Texturen (relative Pfade für den Asset-Manager) ──────────────────────
public String barkTexture = null; // z.B. "Textures/internal/bark/Bark001_Color.jpg"
public String leafTexture = null; // z.B. "Textures/internal/leaves/oak.png"
public String leafTexture = null; // z.B. "Textures/internal/foliage/oak.png"
// ── Wind ─────────────────────────────────────────────────────────────────
public float trunkFlexibility = 0.05f;
@@ -68,7 +68,7 @@ public class TreeParams {
p.leafScale = 1.4f; p.leafCount = 6; p.leafAngle = 42f; p.leafBranchings = 2;
p.trunkFlexibility = 0.04f; p.branchFlexibility = 0.85f;
p.barkTexture = "Textures/internal/bark/Bark001_Color.jpg";
p.leafTexture = "Textures/internal/leaves/oak.png";
p.leafTexture = "Textures/internal/foliage/oak.png";
return p;
}
@@ -89,7 +89,7 @@ public class TreeParams {
p.leafScale = 0.9f; p.leafCount = 4; p.leafAngle = 38f; p.leafBranchings = 1;
p.trunkFlexibility = 0.03f; p.branchFlexibility = 0.95f;
p.barkTexture = "Textures/internal/bark/Bark002_Color.jpg";
p.leafTexture = "Textures/internal/leaves/aspen.png";
p.leafTexture = "Textures/internal/foliage/aspen.png";
return p;
}
@@ -110,7 +110,7 @@ public class TreeParams {
p.leafScale = 0.7f; p.leafCount = 8; p.leafAngle = 70f; p.leafBranchings = 1;
p.trunkFlexibility = 0.03f; p.branchFlexibility = 0.70f;
p.barkTexture = "Textures/internal/bark/Bark003_Color.jpg";
p.leafTexture = "Textures/internal/leaves/pine.png";
p.leafTexture = "Textures/internal/foliage/pine.png";
return p;
}
@@ -131,7 +131,7 @@ public class TreeParams {
p.leafScale = 1.5f; p.leafCount = 7; p.leafAngle = 55f; p.leafBranchings = 2;
p.trunkFlexibility = 0.06f; p.branchFlexibility = 0.98f;
p.barkTexture = "Textures/internal/bark/Bark001_Color.jpg";
p.leafTexture = "Textures/internal/leaves/ash.png";
p.leafTexture = "Textures/internal/foliage/ash.png";
return p;
}
@@ -152,7 +152,7 @@ public class TreeParams {
p.leafScale = 1.0f; p.leafCount = 5; p.leafAngle = 50f; p.leafBranchings = 2;
p.trunkFlexibility = 0.05f; p.branchFlexibility = 0.90f;
p.barkTexture = "Textures/internal/bark/Bark001_Color.jpg";
p.leafTexture = "Textures/internal/leaves/ash.png";
p.leafTexture = "Textures/internal/foliage/ash.png";
return p;
}

View File

@@ -386,10 +386,14 @@ public final class ImpostorUtil {
for (Spatial s : root.getChildren()) {
if (s instanceof Geometry g) {
Material mat = g.getMaterial();
if (mat.getMaterialDef().getMaterialParam("LightDir") != null) {
mat.setVector3("LightDir", lightDir);
mat.setVector3("SunColor", new Vector3f(1.3f, 1.2f, 1.0f));
mat.setVector3("AmbientColor", new Vector3f(0.55f, 0.55f, 0.57f));
if (mat == null) { continue; }
com.jme3.material.MaterialDef def = mat.getMaterialDef();
if (def.getMaterialParam("LightDir") != null) {
mat.setVector3("LightDir", lightDir);
}
if (def.getMaterialParam("SunColor") != null) {
mat.setVector3("SunColor", new Vector3f(1.4f, 1.3f, 1.1f));
mat.setVector3("AmbientColor", new Vector3f(0.18f, 0.18f, 0.22f));
}
} else if (s instanceof Node n) {
applyTreeLighting(n, lightDir);