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 0000000..6867b5c Binary files /dev/null and b/blight-map/src/main/map/chunks/sculpt_16_0_07.blsm differ