From 753af2dc13cf786d1cfd29b5b54f89da5deabce9 Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 10 Aug 2026 14:41:44 +0200 Subject: [PATCH] Platzierung und Bearbeitung von OPbjekten angepasst --- .../main/java/de/blight/editor/EditorApp.java | 14 +- .../java/de/blight/editor/SharedInput.java | 4 + .../blight/editor/state/SceneObjectState.java | 351 ++++++++++++++---- blight-map/src/main/map/blight_objects.blo | 4 + .../src/main/map/chunks/sculpt_16_0_07.blsm | Bin 0 -> 28248 bytes 5 files changed, 306 insertions(+), 67 deletions(-) create mode 100644 blight-map/src/main/map/chunks/sculpt_16_0_07.blsm diff --git a/blight-editor/src/main/java/de/blight/editor/EditorApp.java b/blight-editor/src/main/java/de/blight/editor/EditorApp.java index 4607bb3..e170017 100644 --- a/blight-editor/src/main/java/de/blight/editor/EditorApp.java +++ b/blight-editor/src/main/java/de/blight/editor/EditorApp.java @@ -2398,6 +2398,15 @@ public class EditorApp extends Application { } inner.getChildren().add(grid); + // ── Terrain-Ausrichten ──────────────────────────────────────────────── + inner.getChildren().addAll(sectionTitle("Terrain-Ausrichten"), new Separator()); + CheckBox alignCB = new CheckBox("An Terrain angleichen"); + alignCB.setSelected(input.terrainAlignEnabled); + alignCB.setStyle("-fx-text-fill: #111111;"); + alignCB.setOnAction(e -> input.terrainAlignEnabled = alignCB.isSelected()); + inner.getChildren().add(alignCB); + inner.getChildren().add(styledHint("Passt rotX/rotZ an die Geländesteigung an")); + // ── Vertex-Snap (beim Platzieren von custom Meshes) ─────────────────── inner.getChildren().addAll(sectionTitle("Vertex-Snap"), new Separator()); CheckBox placeSnapCB = new CheckBox("Snap aktiv"); @@ -2467,7 +2476,7 @@ public class EditorApp extends Application { record ToolEntry(String label, String tooltip, int mode) {} var tools = List.of( new ToolEntry("Bewegen", "Objekte entlang X/Y/Z verschieben", SharedInput.EDIT_TOOL_MOVE), - new ToolEntry("Rotieren", "Objekte rotieren (noch nicht implementiert)", SharedInput.EDIT_TOOL_ROTATE), + new ToolEntry("Rotieren", "Objekte um X/Y/Z rotieren (Ring-Gizmo)", SharedInput.EDIT_TOOL_ROTATE), new ToolEntry("Skalieren", "Objekte skalieren (noch nicht implementiert)", SharedInput.EDIT_TOOL_SCALE) ); @@ -2481,8 +2490,7 @@ public class EditorApp extends Application { if (t.mode() == input.objectEditTool) btn.setSelected(true); int mode = t.mode(); btn.setOnAction(e -> input.objectEditTool = mode); - // Noch nicht implementierte Tools ausgegraut - if (t.mode() != SharedInput.EDIT_TOOL_MOVE) btn.setStyle("-fx-opacity: 0.55;"); + if (t.mode() == SharedInput.EDIT_TOOL_SCALE) btn.setStyle("-fx-opacity: 0.55;"); bar.getChildren().add(btn); } bar.setStyle("-fx-font-size: 11;"); diff --git a/blight-editor/src/main/java/de/blight/editor/SharedInput.java b/blight-editor/src/main/java/de/blight/editor/SharedInput.java index a79b515..64dd4e5 100644 --- a/blight-editor/src/main/java/de/blight/editor/SharedInput.java +++ b/blight-editor/src/main/java/de/blight/editor/SharedInput.java @@ -382,6 +382,10 @@ public class SharedInput { /** Status-/Fehlermeldungen von JME an JavaFX-Statusleiste. */ public volatile String consoleOutput = null; + // ── Terrain-Ausrichten ──────────────────────────────────────────────────── + /** Wenn true: Objekte beim Platzieren an die Geländeneigung anpassen (rotX/rotZ). */ + public volatile boolean terrainAlignEnabled = false; + // ── Vertex-Snap ─────────────────────────────────────────────────────────── /** Wenn true: Punkte, die beim Ziehen in Snap-Radius geraten, verschmelzen. */ public volatile boolean vertexSnapEnabled = false; diff --git a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java index 4bacc66..5db5fa0 100644 --- a/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java +++ b/blight-editor/src/main/java/de/blight/editor/state/SceneObjectState.java @@ -94,12 +94,20 @@ public class SceneObjectState extends BaseAppState { private Node arrowX, arrowY, arrowZ; // Verschiebe-Pfeile private Node ringX, ringY, ringZ; // Rotationsringe + /** Gecachter Gizmo-Radius pro Objekt-Node (berechnet vor Rotation → rotationsunabhängig). */ + private final java.util.Map cachedGizmoRadii = new java.util.HashMap<>(); + private final List selectedIndices = new ArrayList<>(); private int activeGizmo = -1; // 0=X,1=Y,2=Z (Verschieben); -1=keins private int activeRing = -1; // 0=X,1=Y,2=Z (Rotieren); -1=keins private Node previewNode; private String previewModelPath; // gecachter Pfad, um Reload zu vermeiden + private final List previewMaterials = new ArrayList<>(); + private boolean previewValid = true; + + private static final ColorRGBA PREVIEW_COL_VALID = new ColorRGBA(0.3f, 0.8f, 1.0f, 1f); + private static final ColorRGBA PREVIEW_COL_INVALID = new ColorRGBA(1.0f, 0.2f, 0.1f, 1f); // ── Baum-Ordner-Modus ──────────────────────────────────────────────────── private java.util.List folderTreeAssetPaths = new java.util.ArrayList<>(); @@ -122,6 +130,12 @@ public class SceneObjectState extends BaseAppState { private final List pbrMaterials = new ArrayList<>(); // ── Bett-Liegefläche ───────────────────────────────────────────────────── + // ── Terrain-Level-Indikator ─────────────────────────────────────────────── + /** Flacher Ring bei Terrain-Höhe unter dem selektierten Objekt. */ + private Node terrainRingNode = null; + private Node terrainStickNode = null; + private Material terrainIndicatorMat = null; + /** Visualisierungspfeil für die Liegefläche (1,8 m); wird nur gezeigt wenn Bett-Objekt gewählt. */ private Node bedArrowNode = null; /** UUID des Bettes, für das der Pfeil gerade angezeigt wird. */ @@ -282,6 +296,8 @@ public class SceneObjectState extends BaseAppState { subOverlay.setCullHint(Spatial.CullHint.Always); rootNode.attachChild(subOverlay); + buildTerrainLevelIndicator(); + bedArrowNode = new Node("bedArrow"); bedArrowNode.setCullHint(Spatial.CullHint.Always); rootNode.attachChild(bedArrowNode); @@ -297,6 +313,8 @@ public class SceneObjectState extends BaseAppState { gizmoNode.removeFromParent(); previewNode.removeFromParent(); subOverlay.removeFromParent(); + if (terrainRingNode != null) terrainRingNode.removeFromParent(); + if (terrainStickNode != null) terrainStickNode.removeFromParent(); bedArrowNode.removeFromParent(); benchArrowNode.removeFromParent(); } @@ -320,7 +338,7 @@ public class SceneObjectState extends BaseAppState { // Gizmo-Sichtbarkeit immer aktualisieren (auch wenn Layer wechselt) updateGizmoVisibility(); - updateGizmoOrientation(); + updateTerrainLevelIndicator(); // Baum- und Farn-Shader mit Sonnen- und Windwerten versorgen; PBR-Emissive setzen if (!sceneLitMaterials.isEmpty() || !windMaterials.isEmpty() || !pbrMaterials.isEmpty()) { @@ -471,7 +489,7 @@ public class SceneObjectState extends BaseAppState { String modelPath = input.pendingModelPath; if (input.activeLayer != SharedInput.LAYER_OBJECTS || modelPath == null - || input.mouseScreenX < 0 || terrain == null) { + || input.mouseScreenX < 0) { previewNode.setCullHint(Spatial.CullHint.Always); return; } @@ -479,6 +497,8 @@ public class SceneObjectState extends BaseAppState { if (!modelPath.equals(previewModelPath)) { previewNode.detachAllChildren(); previewModelPath = modelPath; + previewMaterials.clear(); + previewValid = true; try { Spatial model = modelPath.startsWith("@") ? createPrimitiveSpatial(modelPath.substring(1)) @@ -496,18 +516,50 @@ public class SceneObjectState extends BaseAppState { float jmeY = cam.getHeight() - input.mouseScreenY * (float) input.viewportScaleY; Ray ray = screenToRay(jmeX, jmeY); - CollisionResults hits = new CollisionResults(); - terrain.collideWith(ray, hits); - if (hits.size() == 0) { + // Raycast: Basis-Terrain + gebackenes Voxel-Terrain + Vector3f pt = null; + float bestDist = Float.MAX_VALUE; + + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(ray, hits); + if (hits.size() > 0) { + CollisionResult cr = hits.getClosestCollision(); + pt = cr.getContactPoint(); + bestDist = cr.getDistance(); + } + } + + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f[] hit = smes.raycastGeometryWithNormal(ray); + if (hit != null) { + float d = ray.getOrigin().distance(hit[0]); + if (d < bestDist) { + pt = hit[0]; + } + } + } + + if (pt == null) { previewNode.setCullHint(Spatial.CullHint.Always); return; } - Vector3f pt = hits.getClosestCollision().getContactPoint(); if (input.vertexSnapEnabled && modelPath.startsWith("@")) { Vector3f snapped = findNearestVertexWorld(pt, input.vertexSnapRadius); if (snapped != null) pt = snapped; } + + // Gültigkeitsprüfung: ungebackenes Voxel → rot + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + boolean valid = (ves == null) + || (ves.terrainTypeAt(pt.x, pt.z) != VoxelEditorState.TerrainType.VOXEL_UNBAKED); + if (valid != previewValid) { + previewValid = valid; + setPreviewColor(valid ? PREVIEW_COL_VALID : PREVIEW_COL_INVALID); + } + previewNode.setLocalTranslation(pt.x, pt.y, pt.z); float totalRotY = (input.treeFolderPath != null ? currentRandomRotY : 0f) + previewRotY; com.jme3.math.Quaternion rot = new com.jme3.math.Quaternion(); @@ -558,11 +610,12 @@ public class SceneObjectState extends BaseAppState { private void applyPreviewMaterial(Spatial s) { if (s instanceof Geometry geo) { Material mat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); - mat.setColor("Color", new ColorRGBA(0.3f, 0.8f, 1.0f, 1f)); + mat.setColor("Color", PREVIEW_COL_VALID); mat.getAdditionalRenderState().setWireframe(true); mat.getAdditionalRenderState().setDepthTest(false); geo.setMaterial(mat); geo.setQueueBucket(com.jme3.renderer.queue.RenderQueue.Bucket.Transparent); + previewMaterials.add(mat); } else if (s instanceof Node n) { for (Spatial child : new java.util.ArrayList<>(n.getChildren())) { applyPreviewMaterial(child); @@ -570,6 +623,12 @@ public class SceneObjectState extends BaseAppState { } } + private void setPreviewColor(ColorRGBA color) { + for (Material mat : previewMaterials) { + mat.setColor("Color", color); + } + } + // ── Klick-Handling ──────────────────────────────────────────────────────── private void handleClick(SharedInput.ObjectClick click) { @@ -629,21 +688,56 @@ public class SceneObjectState extends BaseAppState { if (input.activeLayer != SharedInput.LAYER_OBJECTS) { deselectAll(); return; } String modelPath = input.pendingModelPath; - if (terrain == null) return; - CollisionResults terrHits = new CollisionResults(); - terrain.collideWith(ray, terrHits); - if (terrHits.size() == 0) return; + // Raycast: Basis-Terrain + gebackenes Voxel-Terrain – nächsten Treffer ermitteln + Vector3f pt = null; + Vector3f ptNormal = new Vector3f(Vector3f.UNIT_Y); + float bestDist = Float.MAX_VALUE; + + if (terrain != null) { + CollisionResults terrHits = new CollisionResults(); + terrain.collideWith(ray, terrHits); + if (terrHits.size() > 0) { + CollisionResult cr = terrHits.getClosestCollision(); + pt = cr.getContactPoint(); + Vector3f cn = cr.getContactNormal(); + if (cn != null && cn.lengthSquared() > 0.01f) { + ptNormal = cn.normalize(); + } + bestDist = cr.getDistance(); + } + } + + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f[] hit = smes.raycastGeometryWithNormal(ray); + if (hit != null) { + float d = ray.getOrigin().distance(hit[0]); + if (d < bestDist) { + pt = hit[0]; + ptNormal = hit[1]; + bestDist = d; + } + } + } + + if (pt == null) { return; } if (modelPath == null) { deselectAll(); return; } - Vector3f pt = terrHits.getClosestCollision().getContactPoint(); + // Platzierung auf ungebackenem Voxel-Terrain blockieren + VoxelEditorState ves = getStateManager().getState(VoxelEditorState.class); + if (ves != null && ves.terrainTypeAt(pt.x, pt.z) == VoxelEditorState.TerrainType.VOXEL_UNBAKED) { + setStatus("Platzierung nicht möglich: Voxel-Terrain zuerst backen!"); + return; + } + if (input.vertexSnapEnabled && modelPath.startsWith("@")) { Vector3f snapped = findNearestVertexWorld(pt, input.vertexSnapRadius); if (snapped != null) pt = snapped; } previewNode.setCullHint(Spatial.CullHint.Always); float rotY = (input.treeFolderPath != null ? currentRandomRotY : 0f) + previewRotY; - placeObject(modelPath, pt.x, pt.z, pt.y, rotY); + placeObject(modelPath, pt.x, pt.z, pt.y, rotY, ptNormal); if (input.treeFolderPath != null) { applyRandomTree(); previewRotY = 0f; @@ -652,7 +746,7 @@ public class SceneObjectState extends BaseAppState { // ── Objekt platzieren ──────────────────────────────────────────────────── - private void placeObject(String modelPath, float wx, float wz, float wy, float rotY) { + private void placeObject(String modelPath, float wx, float wz, float wy, float rotY, Vector3f terrNormal) { // Meta-Defaults anwenden wenn vorhanden de.blight.common.ModelMeta meta = null; if (!modelPath.startsWith("@")) { @@ -715,7 +809,20 @@ public class SceneObjectState extends BaseAppState { } } } - so.setRotation(0f, rotY, 0f); + // Terrain-Ausrichten: volle Neigungsanpassung via Quaternion (Y → Terrain-Normale) + Quaternion placementRot = new Quaternion().fromAngleAxis(rotY, Vector3f.UNIT_Y); + if (input.terrainAlignEnabled && terrNormal != null && terrNormal.y < 0.9999f) { + Vector3f axis = Vector3f.UNIT_Y.cross(terrNormal); + float axisLen = axis.length(); + if (axisLen > 1e-5f) { + axis.divideLocal(axisLen); + float tiltAngle = FastMath.acos(FastMath.clamp(terrNormal.y, -1f, 1f)); + Quaternion qTilt = new Quaternion().fromAngleAxis(tiltAngle, axis); + placementRot = qTilt.mult(placementRot); + } + } + float[] eulers = placementRot.toAngles(null); + so.setRotation(eulers[0], eulers[1], eulers[2]); so.setScale(defaultScale); so.castShadow = defaultCast; so.receiveShadow = defaultReceive; @@ -724,11 +831,7 @@ public class SceneObjectState extends BaseAppState { Node node = loadModelNode(modelPath, wx, wy + placementOffY, wz); node.setLocalScale(defaultScale); - if (rotY != 0f) { - Quaternion q = new Quaternion(); - q.fromAngleAxis(rotY, Vector3f.UNIT_Y); - node.setLocalRotation(q); - } + node.setLocalRotation(placementRot); objNodes.add(node); objectRoot.attachChild(node); collectLitMaterials(node); @@ -866,6 +969,16 @@ public class SceneObjectState extends BaseAppState { node.attachChild(box); } node.setLocalTranslation(wx, wy, wz); + + // Radius vor Rotation cachen – getWorldBound() ist nach Rotation größer (AABB-Effekt). + node.updateGeometricState(); + BoundingVolume initBv = node.getWorldBound(); + float initR; + if (initBv instanceof BoundingSphere bsI) initR = bsI.getRadius(); + else if (initBv instanceof BoundingBox bbI) initR = bbI.getExtent(null).length(); + else initR = 0.5f; + cachedGizmoRadii.put(node, Math.max(0.5f, initR)); + return node; } @@ -1001,49 +1114,119 @@ public class SceneObjectState extends BaseAppState { } } - /** - * Richtet die drei Translationspfeile so aus, dass sie immer entlang der - * Kamera-Achsen zeigen (screen-right, screen-up, screen-depth). - */ - private void updateGizmoOrientation() { - Vector3f camRight = cam.getLeft().negate(); - Vector3f camUp = cam.getUp(); - Vector3f camForward = cam.getDirection(); - - // arrowX wurde mit dir=(1,0,0) gebaut → drehen auf camRight - arrowX.setLocalRotation(rotFromTo(new Vector3f(1, 0, 0), camRight)); - // arrowY wurde mit dir=(0,1,0) gebaut → drehen auf camUp - arrowY.setLocalRotation(rotFromTo(new Vector3f(0, 1, 0), camUp)); - // arrowZ wurde mit dir=(0,0,-1) gebaut → drehen auf camForward (Tiefe) - arrowZ.setLocalRotation(rotFromTo(new Vector3f(0, 0, -1), camForward)); - } - - /** Gibt die aktuelle Weltrichtung des Gizmo-Pfeils mit Index {@code idx} zurück. */ + /** Gibt die Weltrichtung des Gizmo-Pfeils mit Index {@code idx} zurück (Welt-Achsen: X/Y/-Z). */ private Vector3f getArrowWorldDir(int idx) { - Node node = switch (idx) { case 0 -> arrowX; case 1 -> arrowY; default -> arrowZ; }; - Vector3f localDir = switch (idx) { + return switch (idx) { case 0 -> new Vector3f(1, 0, 0); case 1 -> new Vector3f(0, 1, 0); default -> new Vector3f(0, 0, -1); }; - return node.getWorldRotation().mult(localDir); } - /** Kürzeste Quaternion-Rotation von Einheitsvektor {@code from} nach {@code to}. */ - private static Quaternion rotFromTo(Vector3f from, Vector3f to) { - Vector3f cross = from.cross(to); - float dot = from.dot(to); - if (cross.lengthSquared() < 1e-6f) { - if (dot > 0f) return new Quaternion(); // gleiche Richtung - // Entgegengesetzte Richtung: 180° um eine senkrechte Achse - Vector3f perp = (Math.abs(from.x) < 0.9f) - ? Vector3f.UNIT_X.cross(from).normalizeLocal() - : Vector3f.UNIT_Y.cross(from).normalizeLocal(); - return new Quaternion().fromAngleAxis(FastMath.PI, perp); + + + // ── Terrain-Level-Indikator ─────────────────────────────────────────────── + + private static final float TL_RING_RADIUS = 1.4f; + private static final float TL_TUBE_RADIUS = 0.07f; + private static final float TL_STICK_RADIUS = 0.04f; + + private void buildTerrainLevelIndicator() { + terrainIndicatorMat = new Material(assets, "Common/MatDefs/Misc/Unshaded.j3md"); + terrainIndicatorMat.setColor("Color", new ColorRGBA(0.2f, 1f, 0.2f, 1f)); + terrainIndicatorMat.getAdditionalRenderState().setDepthTest(false); + + // Flacher Ring auf der XZ-Ebene (Torus liegt default in XY → 90° um X kippen) + Geometry ringGeo = new Geometry("terrainRing", + new Torus(32, 8, TL_TUBE_RADIUS, TL_RING_RADIUS)); + ringGeo.setLocalRotation(new Quaternion().fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X)); + ringGeo.setMaterial(terrainIndicatorMat); + ringGeo.setQueueBucket(RenderQueue.Bucket.Transparent); + + terrainRingNode = new Node("terrainRingNode"); + terrainRingNode.attachChild(ringGeo); + terrainRingNode.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(terrainRingNode); + + // Dünner Stab von Terrain-Level bis Objekt (Zylinder entlang Y, Höhe = 1 → wird skaliert) + Cylinder stickMesh = new Cylinder(2, 8, TL_STICK_RADIUS, 1f, true); + Geometry stickGeo = new Geometry("terrainStick", stickMesh); + // Zylinder liegt default entlang Z → um X drehen damit er entlang Y steht + stickGeo.setLocalRotation(new Quaternion().fromAngleAxis(FastMath.HALF_PI, Vector3f.UNIT_X)); + stickGeo.setMaterial(terrainIndicatorMat); + stickGeo.setQueueBucket(RenderQueue.Bucket.Transparent); + + terrainStickNode = new Node("terrainStickNode"); + terrainStickNode.attachChild(stickGeo); + terrainStickNode.setCullHint(Spatial.CullHint.Always); + rootNode.attachChild(terrainStickNode); + } + + private void updateTerrainLevelIndicator() { + if (terrainRingNode == null) return; + if (selectedIndices.isEmpty() || input.activeLayer != SharedInput.LAYER_OBJECTS_EDIT) { + terrainRingNode.setCullHint(Spatial.CullHint.Always); + terrainStickNode.setCullHint(Spatial.CullHint.Always); + return; + } + + int idx = primaryIdx(); + if (idx < 0) { + terrainRingNode.setCullHint(Spatial.CullHint.Always); + terrainStickNode.setCullHint(Spatial.CullHint.Always); + return; + } + + SceneObject so = objects.get(idx); + float wx = so.getWorldX(), wz = so.getWorldZ(), wy = so.getGroundY(); + + // Raycast senkrecht nach unten von deutlich oberhalb des Objekts + Ray downRay = new Ray(new Vector3f(wx, wy + 500f, wz), new Vector3f(0f, -1f, 0f)); + float terrainY = Float.NEGATIVE_INFINITY; + + if (terrain != null) { + CollisionResults hits = new CollisionResults(); + terrain.collideWith(downRay, hits); + if (hits.size() > 0) { + terrainY = Math.max(terrainY, hits.getClosestCollision().getContactPoint().y); + } + } + + SculptedMeshEditorState smes = getStateManager().getState(SculptedMeshEditorState.class); + if (smes != null) { + Vector3f[] hit = smes.raycastGeometryWithNormal(downRay); + if (hit != null) { + terrainY = Math.max(terrainY, hit[0].y); + } + } + + if (terrainY == Float.NEGATIVE_INFINITY) { + terrainRingNode.setCullHint(Spatial.CullHint.Always); + terrainStickNode.setCullHint(Spatial.CullHint.Always); + return; + } + + // Farbe: grün = Objekt über Terrain, rot = Objekt versinkt + boolean above = wy >= terrainY - 0.02f; + ColorRGBA col = above + ? new ColorRGBA(0.2f, 1f, 0.2f, 1f) + : new ColorRGBA(1f, 0.15f, 0.1f, 1f); + terrainIndicatorMat.setColor("Color", col); + + // Ring leicht über Terrain-Oberfläche platzieren (Anti-Z-Fighting) + terrainRingNode.setLocalTranslation(wx, terrainY + 0.05f, wz); + terrainRingNode.setCullHint(Spatial.CullHint.Inherit); + + // Stab zwischen Terrain-Level und Objekt-Y + float height = wy - terrainY; + if (Math.abs(height) > 0.05f) { + float midY = terrainY + height * 0.5f; + terrainStickNode.setLocalTranslation(wx, midY, wz); + terrainStickNode.setLocalScale(1f, Math.abs(height), 1f); + terrainStickNode.setCullHint(Spatial.CullHint.Inherit); + } else { + terrainStickNode.setCullHint(Spatial.CullHint.Always); } - // Halber-Winkel-Trick für Einheitsvektoren: q = (cross, 1+dot), normalisieren - Quaternion q = new Quaternion(cross.x, cross.y, cross.z, 1f + dot); - return q.normalizeLocal(); } /** Zeigt/versteckt Pfeile oder Ringe abhängig von Selektion und aktivem Werkzeug. */ @@ -1152,7 +1335,38 @@ public class SceneObjectState extends BaseAppState { private void handleRotateDrag(SharedInput.ObjectDrag drag) { if (activeRing < 0) return; - float angle = drag.dx() * DEG_PER_PX * FastMath.DEG_TO_RAD; + + // Rotationswinkel per Bildschirm-Projektion des Ring-Tangentenvektors bestimmen. + // X- und Z-Ring folgen der Y-Rotation des Objekts (lokale Achsen), Y-Ring bleibt in Welt-Y. + int pidx = primaryIdx(); + float objRotY = (pidx >= 0) ? objects.get(pidx).getRotY() : 0f; + Quaternion qObjY = new Quaternion().fromAngleAxis(objRotY, Vector3f.UNIT_Y); + Vector3f rotAxis = switch (activeRing) { + case 0 -> qObjY.mult(new Vector3f(1, 0, 0)); // lokales X des Objekts + case 1 -> new Vector3f(0, 1, 0); // immer Welt-Y + default -> qObjY.mult(new Vector3f(0, 0, 1)); // lokales Z des Objekts + }; + Vector3f center = gizmoNode.getWorldTranslation(); + Vector3f toCam = cam.getLocation().subtract(center).normalizeLocal(); + Vector3f tangent = rotAxis.cross(toCam); + float tLen = tangent.length(); + float angle; + if (tLen < 1e-4f) { + angle = drag.dx() * DEG_PER_PX * FastMath.DEG_TO_RAD; + } else { + tangent.divideLocal(tLen); + Vector3f scrC = cam.getScreenCoordinates(center); + Vector3f scrT = cam.getScreenCoordinates(center.add(tangent)); + float sdx = scrT.x - scrC.x; + float sdy = -(scrT.y - scrC.y); // JME Y↑, Drag Y↓ → umkehren + float sl2 = sdx * sdx + sdy * sdy; + if (sl2 < 0.5f) { + angle = drag.dx() * DEG_PER_PX * FastMath.DEG_TO_RAD; + } else { + float proj = (drag.dx() * sdx + drag.dy() * sdy) / sl2; + angle = proj / RING_RADIUS; + } + } int mode = input.objectSelectionMode; if (mode != SharedInput.SEL_MODE_OBJECT && subSelGeom != null) { @@ -1238,7 +1452,7 @@ public class SceneObjectState extends BaseAppState { Geometry ring = new Geometry(name + "_ring", new Torus(48, 10, RING_TUBE, RING_RADIUS)); ring.setMaterial(mat); - ring.setQueueBucket(RenderQueue.Bucket.Transparent); + ring.setQueueBucket(RenderQueue.Bucket.Translucent); Node node = new Node(name); node.setLocalRotation(nodeRot); @@ -1261,14 +1475,14 @@ public class SceneObjectState extends BaseAppState { shaft.setLocalRotation(geomRot); shaft.setLocalTranslation(dir.mult(SHAFT_LEN * 0.5f)); shaft.setMaterial(mat); - shaft.setQueueBucket(RenderQueue.Bucket.Transparent); + shaft.setQueueBucket(RenderQueue.Bucket.Translucent); Cylinder headMesh = new Cylinder(2, 10, HEAD_RADIUS, HEAD_LEN, true); Geometry head = new Geometry(name + "_head", headMesh); head.setLocalRotation(geomRot); head.setLocalTranslation(dir.mult(SHAFT_LEN + HEAD_LEN * 0.5f)); head.setMaterial(mat); - head.setQueueBucket(RenderQueue.Bucket.Transparent); + head.setQueueBucket(RenderQueue.Bucket.Translucent); Node node = new Node(name); node.attachChild(shaft); @@ -1320,20 +1534,27 @@ public class SceneObjectState extends BaseAppState { } // Objekt-Modus: Gizmo am Schwerpunkt aller selektierten Objekte + // Radius aus Cache (unrotiert) – vermeidet wachsende Ringe beim Kippen des Objekts. float cx = 0, cy = 0, cz = 0, maxR = 0.5f; for (int i : selectedIndices) { Vector3f wt = objNodes.get(i).getWorldTranslation(); cx += wt.x; cy += wt.y; cz += wt.z; - BoundingVolume bv = objNodes.get(i).getWorldBound(); - float r; - if (bv instanceof BoundingSphere bs) r = bs.getRadius(); - else if (bv instanceof BoundingBox bb) r = bb.getExtent(null).length(); - else r = 0.5f; + float r = cachedGizmoRadii.getOrDefault(objNodes.get(i), 0.5f); if (r > maxR) maxR = r; } int n = selectedIndices.size(); gizmoNode.setLocalTranslation(cx / n, cy / n, cz / n); gizmoNode.setLocalScale(Math.max(0.01f, maxR / RING_RADIUS)); + + // Ringe an die Y-Rotation des primären Objekts ausrichten: + // rotX/rotZ entsprechen Rotationen um die lokalen Achsen des Objekts (nach Y-Rotation), + // daher sollen die Ringe diese Kipprichtung zeigen, nicht die Weltachsen. + int pidx = primaryIdx(); + float objRotY = (pidx >= 0) ? objects.get(pidx).getRotY() : 0f; + Quaternion qObjY = new Quaternion().fromAngleAxis(objRotY, Vector3f.UNIT_Y); + ringX.setLocalRotation(qObjY.mult(new Quaternion().fromAngleAxis( FastMath.HALF_PI, Vector3f.UNIT_Y))); + ringY.setLocalRotation(qObjY.mult(new Quaternion().fromAngleAxis( FastMath.HALF_PI, Vector3f.UNIT_X))); + ringZ.setLocalRotation(qObjY.mult(new Quaternion())); } // ── Gizmo-Picking ───────────────────────────────────────────────────────── @@ -1627,6 +1848,7 @@ public class SceneObjectState extends BaseAppState { SceneObject so = objects.get(idx); deleteInteractableFile(so); objectRoot.detachChild(objNodes.get(idx)); + cachedGizmoRadii.remove(objNodes.get(idx)); objects.remove(idx); objNodes.remove(idx); animClips.remove(idx); @@ -1732,6 +1954,7 @@ public class SceneObjectState extends BaseAppState { // Originals entfernen for (int idx : sortedDesc) { objectRoot.detachChild(objNodes.get(idx)); + cachedGizmoRadii.remove(objNodes.get(idx)); objects.remove(idx); objNodes.remove(idx); animClips.remove(idx); diff --git a/blight-map/src/main/map/blight_objects.blo b/blight-map/src/main/map/blight_objects.blo index ea898e7..8e81811 100644 --- a/blight-map/src/main/map/blight_objects.blo +++ b/blight-map/src/main/map/blight_objects.blo @@ -5,3 +5,7 @@ Models/trees/pine/medium/pine_medium_20260706_190953.j3o -17.84356 1.24567 -1316 Models/plants/misc/kaktusfeige.j3o -6.18754 1.22995 -1320.34875 0.00000 2.50000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 Models/imported/alter_steg.j3o -8.42317 -0.11015 -1359.15662 3.14159 1.00000 0.00000 0.00000 true true true 30.00000 80.00000 120.00000 Models/trees/grapevine/grapevine_20260725_195635.j3o -85.78869 1.32641 -1297.17883 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260706_190939.j3o 56.66702 5.49606 -1107.68445 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260706_190953.j3o 71.94359 5.49606 -1112.82898 0.00000 1.00000 0.00000 0.00000 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260706_190947.j3o 78.22437 5.23387 -1107.42407 -1.45721 1.00000 0.06351 -0.13191 false true true 30.00000 80.00000 120.00000 +Models/trees/pine/medium/pine_medium_20260706_190947.j3o 104.65085 0.89589 -1111.47168 -1.67187 1.00000 0.04752 0.04310 false true true 30.00000 80.00000 120.00000 diff --git a/blight-map/src/main/map/chunks/sculpt_16_0_07.blsm b/blight-map/src/main/map/chunks/sculpt_16_0_07.blsm new file mode 100644 index 0000000000000000000000000000000000000000..6867b5c03be1b7f86f2bae5dff9309128af36662 GIT binary patch literal 28248 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 Y@apGzBJ9y{d5wm`dsdz}ICT{M4;7Hae*gdg literal 0 HcmV?d00001