From 6a3eb89aafd47d3cef350db46fc9de26fe07910b Mon Sep 17 00:00:00 2001 From: tzdwindows 7 <3076584115@qq.com> Date: Sat, 18 Oct 2025 15:27:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(render):=20=E5=AE=9E=E7=8E=B0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=83=A8=E4=BB=B6=E5=8F=98=E6=8D=A2=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 TransformPanel 类,提供图形界面控制模型部件的位移、旋转、缩放和中心点 - 在 ModelLayerPanelTest 中集成变换面板,支持自动更新选中部件 - 为 ModelPart 添加事件系统,支持变换属性变更通知 - 实现 Mesh2D 的 pivot 和 originalPivot 分离,支持更精确的变换控制- 添加 ModelEvent 接口,用于模型部件事件触发机制 - 优化 ModelRenderPanel 的选中部件获取逻辑 - 完善模型点击监听器,支持自动切换到变换控制选项卡 -修复拖拽移动中心点时的边界检查问题 - 增强各变换操作的边界验证和错误处理 - 改进中心点绘制逻辑,增加边界检查和回退机制 重要更新 - 修复上个版本的所有问题,并且增加新的面板观测图层的各种信息 --- .../vivid2D/render/awt/ModelLayerPanel.java | 2 +- .../vivid2D/render/awt/ModelRenderPanel.java | 22 +- .../vivid2D/render/awt/TransformPanel.java | 474 ++++++++++++++++++ .../vivid2D/render/model/ModelEvent.java | 9 + .../vivid2D/render/model/ModelPart.java | 186 ++++++- .../vivid2D/render/model/util/Mesh2D.java | 142 ++++-- .../vivid2D/test/ModelLayerPanelTest.java | 65 ++- 7 files changed, 815 insertions(+), 85 deletions(-) create mode 100644 src/main/java/com/chuangzhou/vivid2D/render/awt/TransformPanel.java create mode 100644 src/main/java/com/chuangzhou/vivid2D/render/model/ModelEvent.java diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java index ff30363..9bbbb9b 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelLayerPanel.java @@ -986,4 +986,4 @@ public class ModelLayerPanel extends JPanel { e.printStackTrace(); } } -} +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java index 059ce74..0f02ae7 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java @@ -168,6 +168,13 @@ public class ModelRenderPanel extends JPanel { }); } + /** + * 获取当前选中的部件 + */ + public ModelPart getSelectedPart() { + return selectedMesh != null ? findPartByMesh(selectedMesh) : null; + } + /** * 获取鼠标悬停的网格 */ @@ -324,8 +331,6 @@ public class ModelRenderPanel extends JPanel { partInitialRotation = selPart.getRotation(); } - logger.info("开始旋转,中心点: ({}, {})", rotationCenter.x, rotationCenter.y); - }else if (dragMode == DragMode.MOVE_PIVOT && selectedMesh != null) { // 开始移动中心点 currentDragMode = DragMode.MOVE_PIVOT; @@ -336,8 +341,6 @@ public class ModelRenderPanel extends JPanel { BoundingBox bounds = selectedMesh.getBounds(); rotationCenter.set((bounds.getMinX() + bounds.getMaxX()) / 2.0f, (bounds.getMinY() + bounds.getMaxY()) / 2.0f); - - logger.info("开始移动中心点"); } else if (dragMode != DragMode.NONE && selectedMesh != null) { // 开始调整大小 currentDragMode = dragMode; @@ -403,7 +406,7 @@ public class ModelRenderPanel extends JPanel { float maxX = bounds.getMaxX(); float maxY = bounds.getMaxY(); - // 使用 Mesh2D 的实际中心点,而不是边界框中心 + // 使用 Mesh2D 的实际中心点 Vector2f actualPivot = selectedMesh.getPivot(); float centerX = actualPivot.x; float centerY = actualPivot.y; @@ -584,15 +587,18 @@ public class ModelRenderPanel extends JPanel { if (selectedMesh == null) return; ModelPart selectedPart = findPartByMesh(selectedMesh); if (selectedPart == null) return; + float deltaX = modelX - dragStartX; float deltaY = modelY - dragStartY; Vector2f currentPivot = selectedPart.getPivot(); float newPivotX = currentPivot.x + deltaX; float newPivotY = currentPivot.y + deltaY; - selectedPart.setPivot(newPivotX, newPivotY); - rotationCenter.set(newPivotX, newPivotY); + if (!selectedPart.setPivot(newPivotX, newPivotY)) { + return; + } dragStartX = modelX; dragStartY = modelY; + rotationCenter.set(newPivotX, newPivotY); } /** @@ -1442,4 +1448,4 @@ public class ModelRenderPanel extends JPanel { logger.info("OpenGL 资源已清理"); } -} +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/TransformPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/TransformPanel.java new file mode 100644 index 0000000..a898562 --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/TransformPanel.java @@ -0,0 +1,474 @@ +package com.chuangzhou.vivid2D.render.awt; + +import com.chuangzhou.vivid2D.render.model.ModelPart; +import com.chuangzhou.vivid2D.render.model.ModelEvent; +import org.joml.Vector2f; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; + +/** + * @author tzdwindows 7 + */ +public class TransformPanel extends JPanel implements ModelEvent { + private ModelRenderPanel renderPanel; + private ModelPart selectedPart; + + // 位置控制 + private JTextField positionXField; + private JTextField positionYField; + + // 旋转控制 + private JTextField rotationField; + + // 缩放控制 + private JTextField scaleXField; + private JTextField scaleYField; + + // 中心点控制 + private JTextField pivotXField; + private JTextField pivotYField; + + // 按钮 + private JButton flipXButton; + private JButton flipYButton; + private JButton rotate90CWButton; + private JButton rotate90CCWButton; + private JButton resetScaleButton; + + private boolean updatingUI = false; // 防止UI更新时触发事件 + private javax.swing.Timer transformTimer; // 用于延迟处理变换输入 + + public TransformPanel(ModelRenderPanel renderPanel) { + this.renderPanel = renderPanel; + initComponents(); + setupListeners(); + updateUIState(); + } + + private void initComponents() { + setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(3, 5, 3, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + + int row = 0; + + // 位置控制 + gbc.gridx = 0; + gbc.gridy = row; + add(new JLabel("位置 X:"), gbc); + + gbc.gridx = 1; + gbc.gridy = row; + positionXField = new JTextField("0.0"); + add(positionXField, gbc); + + gbc.gridx = 2; + gbc.gridy = row; + add(new JLabel("Y:"), gbc); + + gbc.gridx = 3; + gbc.gridy = row++; + positionYField = new JTextField("0.0"); + add(positionYField, gbc); + + // 旋转控制 + gbc.gridx = 0; + gbc.gridy = row; + add(new JLabel("旋转角度:"), gbc); + + gbc.gridx = 1; + gbc.gridy = row; + rotationField = new JTextField("0.0"); + add(rotationField, gbc); + + gbc.gridx = 2; + gbc.gridy = row; + gbc.gridwidth = 2; + rotate90CWButton = new JButton("+90°"); + rotate90CWButton.setToolTipText("顺时针旋转90度"); + add(rotate90CWButton, gbc); + + gbc.gridx = 0; + gbc.gridy = ++row; + gbc.gridwidth = 4; + rotate90CCWButton = new JButton("-90°"); + rotate90CCWButton.setToolTipText("逆时针旋转90度"); + add(rotate90CCWButton, gbc); + + // 缩放控制 + gbc.gridx = 0; + gbc.gridy = ++row; + gbc.gridwidth = 1; + add(new JLabel("缩放 X:"), gbc); + + gbc.gridx = 1; + gbc.gridy = row; + scaleXField = new JTextField("1.0"); + add(scaleXField, gbc); + + gbc.gridx = 2; + gbc.gridy = row; + add(new JLabel("Y:"), gbc); + + gbc.gridx = 3; + gbc.gridy = row; + scaleYField = new JTextField("1.0"); + add(scaleYField, gbc); + + gbc.gridx = 0; + gbc.gridy = ++row; + gbc.gridwidth = 2; + flipXButton = new JButton("水平翻转"); + add(flipXButton, gbc); + + gbc.gridx = 2; + gbc.gridy = row; + gbc.gridwidth = 2; + flipYButton = new JButton("垂直翻转"); + add(flipYButton, gbc); + + gbc.gridx = 0; + gbc.gridy = ++row; + gbc.gridwidth = 4; + resetScaleButton = new JButton("重置缩放"); + resetScaleButton.setToolTipText("重置为1:1缩放"); + add(resetScaleButton, gbc); + + // 中心点控制 + gbc.gridx = 0; + gbc.gridy = ++row; + gbc.gridwidth = 1; + add(new JLabel("中心点 X:"), gbc); + + gbc.gridx = 1; + gbc.gridy = row; + pivotXField = new JTextField("0.0"); + add(pivotXField, gbc); + + gbc.gridx = 2; + gbc.gridy = row; + add(new JLabel("Y:"), gbc); + + gbc.gridx = 3; + gbc.gridy = row; + pivotYField = new JTextField("0.0"); + add(pivotYField, gbc); + + // Set border + setBorder(BorderFactory.createTitledBorder("变换控制")); + + // 初始化定时器,用于延迟处理变换输入 + transformTimer = new javax.swing.Timer(300, e -> applyTransformChanges()); + transformTimer.setRepeats(false); // 只执行一次 + } + + private void setupListeners() { + // 为所有文本框添加文档监听器 + DocumentListener documentListener = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + scheduleTransformUpdate(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + scheduleTransformUpdate(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + scheduleTransformUpdate(); + } + }; + + positionXField.getDocument().addDocumentListener(documentListener); + positionYField.getDocument().addDocumentListener(documentListener); + rotationField.getDocument().addDocumentListener(documentListener); + scaleXField.getDocument().addDocumentListener(documentListener); + scaleYField.getDocument().addDocumentListener(documentListener); + pivotXField.getDocument().addDocumentListener(documentListener); + pivotYField.getDocument().addDocumentListener(documentListener); + + // 添加焦点监听,当失去焦点时立即应用 + java.awt.event.FocusAdapter focusAdapter = new java.awt.event.FocusAdapter() { + @Override + public void focusLost(java.awt.event.FocusEvent e) { + transformTimer.stop(); + applyTransformChanges(); + } + }; + + positionXField.addFocusListener(focusAdapter); + positionYField.addFocusListener(focusAdapter); + rotationField.addFocusListener(focusAdapter); + scaleXField.addFocusListener(focusAdapter); + scaleYField.addFocusListener(focusAdapter); + pivotXField.addFocusListener(focusAdapter); + pivotYField.addFocusListener(focusAdapter); + + // 添加回车键监听 + java.awt.event.ActionListener enterListener = e -> { + transformTimer.stop(); + applyTransformChanges(); + }; + + positionXField.addActionListener(enterListener); + positionYField.addActionListener(enterListener); + rotationField.addActionListener(enterListener); + scaleXField.addActionListener(enterListener); + scaleYField.addActionListener(enterListener); + pivotXField.addActionListener(enterListener); + pivotYField.addActionListener(enterListener); + + // 按钮监听器 + rotate90CWButton.addActionListener(e -> { + if (selectedPart != null) { + renderPanel.executeInGLContext(() -> { + float currentRotation = (float) Math.toDegrees(selectedPart.getRotation()); + float newRotation = normalizeAngle(currentRotation + 90.0f); + selectedPart.setRotation((float) Math.toRadians(newRotation)); + renderPanel.repaint(); + }); + } + }); + + rotate90CCWButton.addActionListener(e -> { + if (selectedPart != null) { + renderPanel.executeInGLContext(() -> { + float currentRotation = (float) Math.toDegrees(selectedPart.getRotation()); + float newRotation = normalizeAngle(currentRotation - 90.0f); + selectedPart.setRotation((float) Math.toRadians(newRotation)); + renderPanel.repaint(); + }); + } + }); + + flipXButton.addActionListener(e -> { + if (selectedPart != null) { + renderPanel.executeInGLContext(() -> { + float currentScaleX = selectedPart.getScaleX(); + float currentScaleY = selectedPart.getScaleY(); + selectedPart.setScale(currentScaleX * -1, currentScaleY); + renderPanel.repaint(); + }); + } + }); + + flipYButton.addActionListener(e -> { + if (selectedPart != null) { + renderPanel.executeInGLContext(() -> { + float currentScaleX = selectedPart.getScaleX(); + float currentScaleY = selectedPart.getScaleY(); + selectedPart.setScale(currentScaleX, currentScaleY * -1); + renderPanel.repaint(); + }); + } + }); + + resetScaleButton.addActionListener(e -> { + if (selectedPart != null) { + renderPanel.executeInGLContext(() -> { + selectedPart.setScale(1.0f, 1.0f); + renderPanel.repaint(); + }); + } + }); + } + + /** + * 事件监听器实现 - 当ModelPart的属性变化时自动更新UI + */ + @Override + public void trigger(String eventName, Object source) { + if (!(source instanceof ModelPart) || source != selectedPart) return; + + SwingUtilities.invokeLater(() -> { + updatingUI = true; + try { + ModelPart part = (ModelPart) source; + switch (eventName) { + case "position": + Vector2f position = part.getPosition(); + positionXField.setText(String.format("%.2f", position.x)); + positionYField.setText(String.format("%.2f", position.y)); + break; + case "rotation": + float currentRotation = (float) Math.toDegrees(part.getRotation()); + currentRotation = normalizeAngle(currentRotation); + rotationField.setText(String.format("%.2f", currentRotation)); + break; + case "scale": + Vector2f scale = part.getScale(); + scaleXField.setText(String.format("%.2f", scale.x)); + scaleYField.setText(String.format("%.2f", scale.y)); + break; + case "pivot": + Vector2f pivot = part.getPivot(); + pivotXField.setText(String.format("%.2f", pivot.x)); + pivotYField.setText(String.format("%.2f", pivot.y)); + break; + } + } catch (Exception ex) { + ex.printStackTrace(); + } + updatingUI = false; + }); + } + + /** + * 调度变换更新(延迟处理) + */ + private void scheduleTransformUpdate() { + if (updatingUI || selectedPart == null) return; + transformTimer.stop(); + transformTimer.start(); + } + + /** + * 将角度标准化到0-360度范围内 + */ + private float normalizeAngle(float degrees) { + degrees = degrees % 360; + if (degrees < 0) { + degrees += 360; + } + return degrees; + } + + /** + * 应用所有变换更改 + */ + private void applyTransformChanges() { + if (updatingUI || selectedPart == null) return; + + renderPanel.executeInGLContext(() -> { + try { + // 应用位置变化 + float posX = Float.parseFloat(positionXField.getText()); + float posY = Float.parseFloat(positionYField.getText()); + selectedPart.setPosition(posX, posY); + + // 应用旋转变化 + float rotationDegrees = Float.parseFloat(rotationField.getText()); + rotationDegrees = normalizeAngle(rotationDegrees); + selectedPart.setRotation((float) Math.toRadians(rotationDegrees)); + + // 应用缩放变化 + float scaleX = Float.parseFloat(scaleXField.getText()); + float scaleY = Float.parseFloat(scaleYField.getText()); + selectedPart.setScale(scaleX, scaleY); + + // 应用中心点变化 + float pivotX = Float.parseFloat(pivotXField.getText()); + float pivotY = Float.parseFloat(pivotYField.getText()); + selectedPart.setPivot(pivotX, pivotY); + + renderPanel.repaint(); + } catch (NumberFormatException ex) { + // 输入无效时恢复之前的值 + SwingUtilities.invokeLater(this::updateUIFromSelectedPart); + } + }); + } + + /** + * 从选中的部件更新UI + */ + private void updateUIFromSelectedPart() { + if (selectedPart == null) return; + + updatingUI = true; + try { + // 更新位置 + Vector2f position = selectedPart.getPosition(); + positionXField.setText(String.format("%.2f", position.x)); + positionYField.setText(String.format("%.2f", position.y)); + + // 更新旋转 + float currentRotation = (float) Math.toDegrees(selectedPart.getRotation()); + currentRotation = normalizeAngle(currentRotation); + rotationField.setText(String.format("%.2f", currentRotation)); + + // 更新缩放 + Vector2f scale = selectedPart.getScale(); + scaleXField.setText(String.format("%.2f", scale.x)); + scaleYField.setText(String.format("%.2f", scale.y)); + + // 更新中心点 + Vector2f pivot = selectedPart.getPivot(); + pivotXField.setText(String.format("%.2f", pivot.x)); + pivotYField.setText(String.format("%.2f", pivot.y)); + } catch (Exception ex) { + ex.printStackTrace(); + } + updatingUI = false; + } + + public void setSelectedPart(ModelPart part) { + // 移除旧部件的事件监听 + if (this.selectedPart != null) { + this.selectedPart.removeEvent(this); + } + + this.selectedPart = part; + + // 添加新部件的事件监听 + if (this.selectedPart != null) { + this.selectedPart.addEvent(this); + } + + updateUIState(); + } + + private void updateUIState() { + updatingUI = true; + if (selectedPart != null) { + updateUIFromSelectedPart(); + setControlsEnabled(true); + } else { + // 清空所有字段 + positionXField.setText("0.00"); + positionYField.setText("0.00"); + rotationField.setText("0.00"); + scaleXField.setText("1.00"); + scaleYField.setText("1.00"); + pivotXField.setText("0.00"); + pivotYField.setText("0.00"); + setControlsEnabled(false); + } + updatingUI = false; + } + + private void setControlsEnabled(boolean enabled) { + positionXField.setEnabled(enabled); + positionYField.setEnabled(enabled); + rotationField.setEnabled(enabled); + scaleXField.setEnabled(enabled); + scaleYField.setEnabled(enabled); + pivotXField.setEnabled(enabled); + pivotYField.setEnabled(enabled); + flipXButton.setEnabled(enabled); + flipYButton.setEnabled(enabled); + rotate90CWButton.setEnabled(enabled); + rotate90CCWButton.setEnabled(enabled); + resetScaleButton.setEnabled(enabled); + } + + @Override + public void removeNotify() { + super.removeNotify(); + // 清理定时器资源和事件监听 + if (transformTimer != null) { + transformTimer.stop(); + } + if (selectedPart != null) { + selectedPart.removeEvent(this); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelEvent.java b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelEvent.java new file mode 100644 index 0000000..8aa3896 --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelEvent.java @@ -0,0 +1,9 @@ +package com.chuangzhou.vivid2D.render.model; + +/** + * 模型事件 + * @author tzdwindows 7 + */ +public interface ModelEvent { + void trigger(String eventName,Object eventBus); +} diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java index 0a94ab1..033013e 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java @@ -52,6 +52,8 @@ public class ModelPart { private boolean boundsDirty; private boolean pivotInitialized; + private final List events = new ArrayList<>(); + // ====== 液化模式枚举 ====== public enum LiquifyMode { PUSH, // 推开(从画笔中心向外推) @@ -97,6 +99,20 @@ public class ModelPart { recomputeWorldTransformRecursive(); } + public void addEvent(ModelEvent event) { + events.add(event); + } + + public void removeEvent(ModelEvent event) { + events.remove(event); + } + + private void triggerEvent(String eventName) { + for (ModelEvent event : events) { + event.trigger(eventName,this); + } + } + // ==================== 层级管理 ==================== /** @@ -477,12 +493,19 @@ public class ModelPart { */ public void setPosition(float x, float y) { position.set(x, y); + markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); - // 更新网格顶点位置 + // 不修改 originalPivot,只同步 mesh world pivot + for (Mesh2D mesh : meshes) { + Vector2f worldPivot = Matrix3fUtils.transformPoint(worldTransform, mesh.getOriginalPivot()); + mesh.setPivot(worldPivot.x, worldPivot.y); + } + updateMeshVertices(); + triggerEvent("position"); } /** @@ -525,23 +548,54 @@ public class ModelPart { recomputeWorldTransformRecursive(); } - // 应用当前世界变换到每个顶点 + // 应用当前世界变换到每个顶点(将局部 original 顶点烘焙为 world 顶点) for (int i = 0; i < originalVertices.length; i += 2) { Vector2f localPoint = new Vector2f(originalVertices[i], originalVertices[i + 1]); - Vector2f worldPoint = localToWorld(localPoint); + Vector2f worldPoint = Matrix3fUtils.transformPoint(worldTransform, localPoint); mesh.setVertex(i / 2, worldPoint.x, worldPoint.y); } + // 同步 mesh 的原始局部 pivot -> 当前世界 pivot(保持可视中心一致) + try { + Vector2f origPivot = mesh.getOriginalPivot(); + Vector2f worldPivot = Matrix3fUtils.transformPoint(worldTransform, origPivot); + mesh.setPivot(worldPivot.x, worldPivot.y); + } catch (Exception ignored) { } + // 标记网格需要更新 mesh.markDirty(); } - public void setPosition(Vector2f position) { - this.position.set(position); + public void setPosition(Vector2f pos) { + // 记录旧世界变换和旧位置,用于计算位移 + Matrix3f oldWorldTransform = new Matrix3f(this.worldTransform); + Vector2f oldPosition = new Vector2f(this.position); + + // 更新部件的局部位置 + this.position.set(pos); + + // 标记变换脏,更新局部变换和世界变换 markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + // 计算部件的实际位移 + float dx = this.position.x - oldPosition.x; + float dy = this.position.y - oldPosition.y; + + // 更新每个网格的 pivot 和 originalPivot + for (Mesh2D mesh : meshes) { + // 将 mesh 的原始局部 pivot 变换到旧的世界坐标系 + Vector2f oldWorldPivot = Matrix3fUtils.transformPoint(oldWorldTransform, mesh.getOriginalPivot()); + // 在世界坐标系中应用位移 + Vector2f movedWorldPivot = new Vector2f(oldWorldPivot.x + dx, oldWorldPivot.y + dy); + // 将位移后的世界 pivot 逆变换回新的局部坐标系(即新的 originalPivot) + Vector2f newLocalOriginalPivot = Matrix3fUtils.transformPointInverse(this.worldTransform, movedWorldPivot); + + mesh.setOriginalPivot(newLocalOriginalPivot); + mesh.setPivot(movedWorldPivot.x, movedWorldPivot.y); + } + // 更新网格顶点位置 updateMeshVertices(); } @@ -567,10 +621,28 @@ public class ModelPart { * 设置旋转(弧度) */ public void setRotation(float radians) { + // 记录旧的世界变换,用于计算 pivot 的相对位置 + Matrix3f oldWorldTransform = new Matrix3f(this.worldTransform); + this.rotation = radians; markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + + // 旋转操作会改变部件的局部坐标系,因此需要更新网格的 originalPivot + for (Mesh2D mesh : meshes) { + // 将 mesh 的原始局部 pivot 变换到旧的世界坐标系 + Vector2f oldWorldPivot = Matrix3fUtils.transformPoint(oldWorldTransform, mesh.getOriginalPivot()); + // 将旧的世界 pivot 逆变换回新的局部坐标系(即新的 originalPivot) + Vector2f newLocalOriginalPivot = Matrix3fUtils.transformPointInverse(this.worldTransform, oldWorldPivot); + + mesh.setOriginalPivot(newLocalOriginalPivot); + // 同时更新 mesh 的当前 pivot 到新的世界坐标 + mesh.setPivot(Matrix3fUtils.transformPoint(this.worldTransform, newLocalOriginalPivot)); + } + + updateMeshVertices(); + triggerEvent("rotation"); } /** @@ -581,12 +653,16 @@ public class ModelPart { markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + triggerEvent("rotation"); } /** * 设置缩放 */ public void setScale(float sx, float sy) { + // 记录旧的世界变换,用于计算 pivot 的相对位置 + Matrix3f oldWorldTransform = new Matrix3f(this.worldTransform); + this.scaleX = sx; this.scaleY = sy; scale.set(sx, sy); @@ -594,25 +670,69 @@ public class ModelPart { updateLocalTransform(); recomputeWorldTransformRecursive(); + // 缩放操作会改变部件的局部坐标系,因此需要更新网格的 originalPivot + for (Mesh2D mesh : meshes) { + // 将 mesh 的原始局部 pivot 变换到旧的世界坐标系 + Vector2f oldWorldPivot = Matrix3fUtils.transformPoint(oldWorldTransform, mesh.getOriginalPivot()); + // 将旧的世界 pivot 逆变换回新的局部坐标系(即新的 originalPivot) + Vector2f newLocalOriginalPivot = Matrix3fUtils.transformPointInverse(this.worldTransform, oldWorldPivot); + + mesh.setOriginalPivot(newLocalOriginalPivot); + // 同时更新 mesh 的当前 pivot 到新的世界坐标 + mesh.setPivot(Matrix3fUtils.transformPoint(this.worldTransform, newLocalOriginalPivot)); + } updateMeshVertices(); + triggerEvent("scale"); } public void setScale(float uniformScale) { + // 记录旧的世界变换,用于计算 pivot 的相对位置 + Matrix3f oldWorldTransform = new Matrix3f(this.worldTransform); + scale.set(uniformScale, uniformScale); markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + // 缩放操作会改变部件的局部坐标系,因此需要更新网格的 originalPivot + for (Mesh2D mesh : meshes) { + // 将 mesh 的原始局部 pivot 变换到旧的世界坐标系 + Vector2f oldWorldPivot = Matrix3fUtils.transformPoint(oldWorldTransform, mesh.getOriginalPivot()); + // 将旧的世界 pivot 逆变换回新的局部坐标系(即新的 originalPivot) + Vector2f newLocalOriginalPivot = Matrix3fUtils.transformPointInverse(this.worldTransform, oldWorldPivot); + + mesh.setOriginalPivot(newLocalOriginalPivot); + // 同时更新 mesh 的当前 pivot 到新的世界坐标 + mesh.setPivot(Matrix3fUtils.transformPoint(this.worldTransform, newLocalOriginalPivot)); + } + updateMeshVertices(); + triggerEvent("scale"); } public void setScale(Vector2f scale) { + // 记录旧的世界变换,用于计算 pivot 的相对位置 + Matrix3f oldWorldTransform = new Matrix3f(this.worldTransform); + this.scale.set(scale); markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + // 缩放操作会改变部件的局部坐标系,因此需要更新网格的 originalPivot + for (Mesh2D mesh : meshes) { + // 将 mesh 的原始局部 pivot 变换到旧的世界坐标系 + Vector2f oldWorldPivot = Matrix3fUtils.transformPoint(oldWorldTransform, mesh.getOriginalPivot()); + // 将旧的世界 pivot 逆变换回新的局部坐标系(即新的 originalPivot) + Vector2f newLocalOriginalPivot = Matrix3fUtils.transformPointInverse(this.worldTransform, oldWorldPivot); + + mesh.setOriginalPivot(newLocalOriginalPivot); + // 同时更新 mesh 的当前 pivot 到新的世界坐标 + mesh.setPivot(Matrix3fUtils.transformPoint(this.worldTransform, newLocalOriginalPivot)); + } + updateMeshVertices(); + triggerEvent("scale"); } /** @@ -623,6 +743,7 @@ public class ModelPart { markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + triggerEvent("scale"); } // ==================== 网格管理 ==================== @@ -636,7 +757,7 @@ public class ModelPart { // 创建独立副本,避免多个 Part 共享同一 Mesh 实例导致数据冲突 Mesh2D m = mesh.copy(); - // 确保拷贝保留原始的纹理引用(copy() 应该已经赋值,但显式赋值可避免遗漏) + // 确保拷贝保留原始的纹理引用(copy() 已处理) m.setTexture(mesh.getTexture()); // 确保本节点的 worldTransform 是最新的 @@ -645,10 +766,8 @@ public class ModelPart { // 保存拷贝的原始(局部)顶点供后续重算 world 顶点使用 float[] originalVertices = m.getVertices().clone(); m.setOriginalVertices(originalVertices); - // 保证 UV 不被篡改(通常 copy() 已经处理) - // float[] uvs = m.getUVs(); // 如果需要可以在此处检查 - - // 将拷贝的 mesh 的每个顶点从本地空间变换到世界空间(烘焙到 world) + // 把 originalPivot 保存在 mesh 中(setMeshData 已经初始化 originalPivot) + // 将每个顶点从本地空间变换到世界空间(烘焙到 world) int vc = m.getVertexCount(); for (int i = 0; i < vc; i++) { Vector2f local = new Vector2f(originalVertices[i * 2], originalVertices[i * 2 + 1]); @@ -656,6 +775,13 @@ public class ModelPart { m.setVertex(i, worldPt.x, worldPt.y); } + // 同步 originalPivot -> world pivot(如果 originalPivot 有意义) + try { + Vector2f origPivot = m.getOriginalPivot(); + Vector2f worldPivot = Matrix3fUtils.transformPoint(this.worldTransform, origPivot); + m.setPivot(worldPivot.x, worldPivot.y); + } catch (Exception ignored) { } + // 标记为已烘焙到世界坐标(语义上明确),并确保 bounds/dirty 状态被正确刷新 m.setBakedToWorld(true); @@ -670,26 +796,40 @@ public class ModelPart { /** * 设置中心点 */ - public void setPivot(float x, float y) { - if (!pivotInitialized) { - // 确保第一次设置 pivot 的时候,必须是 (0,0) 因为这个为非0,0时后面如果想要热变换就会出问题 - if (x != 0 || y != 0) { - logger.warn("The first time you set the pivot, it must be (0,0), which is automatically adjusted to (0,0)."); - x = 0; - y = 0; + public boolean setPivot(float x, float y) { + // 无论是否首次设置,都允许设置任意 pivot + // pivotInitialized = true; // 此行不再需要,因为不再强制 (0,0) + + for (Mesh2D mesh : meshes) { + // ModelPart 的 pivot 是在部件的局部坐标系中定义的 + // Mesh2D 的 setPivot 期望的是 Mesh2D 自己的局部坐标系中的 pivot + // 因此需要将 ModelPart 的局部 pivot 转换为 Mesh2D 的局部 pivot + // 由于 Mesh2D 的 originalPivot 已经存储了其在 ModelPart 局部坐标系中的相对位置, + // 我们可以直接将 ModelPart 的新 pivot 赋值给 Mesh2D 的 originalPivot + // 然后再通过变换更新 Mesh2D 的实际 pivot + if (!mesh.setOriginalPivot(new Vector2f(x, y))){ + return false; } - pivotInitialized = true; + // Mesh2D 的实际 pivot 应该根据 ModelPart 的世界变换来计算 + // 这里只是设置了 originalPivot,Mesh2D 的实际 pivot 会在 updateMeshVertices 中更新 + // 或者在 ModelPart 的 setPivot 之后,立即触发 Mesh2D 的 pivot 更新 + // 为了简化,我们假设 Mesh2D 的 setPivot 能够处理好 originalPivot 和实际 pivot 的关系 + // 或者在 ModelPart 的 updateMeshVertices 中统一处理 + // 暂时不在这里调用 mesh.setPivot(x, y),因为 Mesh2D.setPivot 有边界检查,可能导致设置失败 + // 正确的做法是更新 Mesh2D 的 originalPivot,然后让 ModelPart 的变换系统来更新 Mesh2D 的实际 pivot + // if (!mesh.setPivot(x, y)){ + // return false; + // } } pivot.set(x, y); - for (Mesh2D mesh : meshes) { - mesh.setPivot(x, y); - } - markTransformDirty(); updateLocalTransform(); recomputeWorldTransformRecursive(); + triggerEvent("pivot"); + updateMeshVertices(); + return true; } /** @@ -984,4 +1124,4 @@ public class ModelPart { ", meshes=" + meshes.size() + '}'; } -} +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java b/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java index 0de7496..f99320c 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/util/Mesh2D.java @@ -51,6 +51,7 @@ public class Mesh2D { private boolean bakedToWorld = false; private volatile boolean selected = false; private Vector2f pivot = new Vector2f(0, 0); + private Vector2f originalPivot = new Vector2f(0, 0); // ==================== 常量 ==================== public static final int POINTS = 0; @@ -101,18 +102,52 @@ public class Mesh2D { this.indices = indices.clone(); this.originalVertices = vertices.clone(); + // 将当前 pivot 视为原始(局部)pivot 的初始值 + this.originalPivot.set(this.pivot); + markDirty(); } /** * 设置中心点 */ - public void setPivot(float x, float y) { - this.pivot.set(x, y); + public boolean setPivot(float x, float y) { + BoundingBox bounds = getBounds(); + if (x >= bounds.getMinX() && x <= bounds.getMaxX() && + y >= bounds.getMinY() && y <= bounds.getMaxY()) { + this.pivot.set(x, y); + return true; + } + return false; } - public void setPivot(Vector2f pivot) { - this.pivot.set(pivot); + public Vector2f getOriginalPivot() { + return new Vector2f(originalPivot); + } + + public boolean setOriginalPivot(Vector2f p) { + if (p != null) { + BoundingBox bounds = getBounds(); + if (bounds != null && + p.x >= bounds.getMinX() && p.x <= bounds.getMaxX() && + p.y >= bounds.getMinY() && p.y <= bounds.getMaxY()) { + this.originalPivot.set(p); + markDirty(); + return true; + } + } + return false; + } + + + public boolean setPivot(Vector2f pivot) { + BoundingBox bounds = getBounds(); + if (pivot.x >= bounds.getMinX() && pivot.x <= bounds.getMaxX() && + pivot.y >= bounds.getMinY() && pivot.y <= bounds.getMaxY()) { + this.pivot.set(pivot); + return true; + } + return false; } /** @@ -126,9 +161,21 @@ public class Mesh2D { * 移动中心点 */ public void movePivot(float dx, float dy) { - this.pivot.add(dx, dy); + float newX = pivot.x + dx; + float newY = pivot.y + dy; + + BoundingBox b = getBounds(); + + if (b != null && newX >= b.getMinX() && newX <= b.getMaxX() + && newY >= b.getMinY() && newY <= b.getMaxY()) { + this.pivot.add(dx, dy); + // 同步原始局部 pivot —— 这里假设 originalPivot 与 pivot 的坐标系一致(多数场景下是这样) + this.originalPivot.add(dx, dy); + markDirty(); + } } + /** * 创建矩形网格 */ @@ -643,16 +690,25 @@ public class Mesh2D { } private void drawCenterPoint(BufferBuilder bb, float minX, float minY, float maxX, float maxY) { - // 使用 Mesh2D 的 pivot 作为中心点位置 + // 使用 Mesh2D 的 pivot 作为中心点位置,但当 pivot 不在 bounds 内时回退为 bounds 中心(避免渲染时跳回 0,0 的情况) float centerX = pivot.x; float centerY = pivot.y; - float pointSize = 6.0f; // 中心点大小 + // 如果 pivot 不在当前 bounds(可能因为 pivot 是局部坐标或坐标空间不一致),回退到 bounds 中心 + boolean pivotInBounds = (centerX >= minX && centerX <= maxX && centerY >= minY && centerY <= maxY); + if (!pivotInBounds) { + // 使用 bounds 中心作为可视化中心,避免显示到 (0,0) + centerX = (minX + maxX) * 0.5f; + centerY = (minY + maxY) * 0.5f; + logger.trace("pivot ({},{}) not in bounds -> using bounds center ({},{}) for rendering", pivot.x, pivot.y, centerX, centerY); + } + + float pointSize = 6.0f; // 中心点大小 Vector4f centerColor = new Vector4f(1.0f, 0.0f, 0.0f, 1.0f); // 红色中心点 // 绘制中心点(十字形) - bb.begin(GL11.GL_LINES, 4); // 使用 RenderSystem 常量 + bb.begin(GL11.GL_LINES, 4); bb.setColor(centerColor); // 水平线 @@ -678,16 +734,22 @@ public class Mesh2D { } bb.endImmediate(); - logger.trace("绘制中心点: ({}, {})", centerX, centerY); + logger.trace("绘制中心点 (rendered): ({}, {}) ; pivot(actual): ({}, {})", centerX, centerY, pivot.x, pivot.y); } + /** * 绘制旋转手柄 */ private void drawRotationHandle(BufferBuilder bb, float minX, float minY, float maxX, float maxY) { - // 使用 Mesh2D 的 pivot 作为中心点位置 + // 计算可视中心(与 drawCenterPoint 的回退逻辑保持一致) float centerX = pivot.x; float centerY = pivot.y; + boolean pivotInBounds = (centerX >= minX && centerX <= maxX && centerY >= minY && centerY <= maxY); + if (!pivotInBounds) { + centerX = (minX + maxX) * 0.5f; + centerY = (minY + maxY) * 0.5f; + } // 旋转手柄位置(在边界框上方) float rotationHandleY = minY - ROTATION_HANDLE_DISTANCE; @@ -719,7 +781,6 @@ public class Mesh2D { bb.begin(GL11.GL_LINES, 4); bb.setColor(rotationColor); - // 箭头线 float arrowSize = 4.0f; bb.vertex(rotationHandleX - arrowSize, rotationHandleY - arrowSize, 0.0f, 0.0f); bb.vertex(rotationHandleX + arrowSize, rotationHandleY + arrowSize, 0.0f, 0.0f); @@ -926,41 +987,42 @@ public class Mesh2D { /** * 创建网格的深拷贝 */ - public Mesh2D copy() { - Mesh2D copy = new Mesh2D(name + "_copy"); + public Mesh2D copy() { + Mesh2D copy = new Mesh2D(name + "_copy"); - // 深拷贝数组(保证互不影响) - copy.vertices = this.vertices != null ? this.vertices.clone() : new float[0]; - copy.uvs = this.uvs != null ? this.uvs.clone() : new float[0]; - copy.indices = this.indices != null ? this.indices.clone() : new int[0]; + // 深拷贝数组(保证互不影响) + copy.vertices = this.vertices != null ? this.vertices.clone() : new float[0]; + copy.uvs = this.uvs != null ? this.uvs.clone() : new float[0]; + copy.indices = this.indices != null ? this.indices.clone() : new int[0]; - // 保留 originalVertices(如果有),否则把当前 vertices 作为原始数据 - copy.originalVertices = this.originalVertices != null ? this.originalVertices.clone() : copy.vertices.clone(); + // 保留 originalVertices(如果有),否则把当前 vertices 作为原始数据 + copy.originalVertices = this.originalVertices != null ? this.originalVertices.clone() : copy.vertices.clone(); - // 复制中心点 - copy.pivot = new Vector2f(this.pivot); + // 复制 pivot 与 originalPivot + copy.pivot = new Vector2f(this.pivot); + copy.originalPivot = new Vector2f(this.originalPivot); - // 复制渲染/状态字段(保留纹理引用,但重置 GPU 句柄) - copy.texture = this.texture; - copy.visible = this.visible; - copy.drawMode = this.drawMode; - copy.bakedToWorld = this.bakedToWorld; + // 复制渲染/状态字段(保留纹理引用,但重置 GPU 句柄) + copy.texture = this.texture; + copy.visible = this.visible; + copy.drawMode = this.drawMode; + copy.bakedToWorld = this.bakedToWorld; - // 重置 GPU 相关句柄,强制重新 uploadToGPU() 在渲染线程执行 - copy.vaoId = -1; - copy.vboId = -1; - copy.eboId = -1; - copy.indexCount = this.indices != null ? this.indices.length : 0; - copy.uploaded = false; + // 重置 GPU 相关句柄,强制重新 uploadToGPU() 在渲染线程执行 + copy.vaoId = -1; + copy.vboId = -1; + copy.eboId = -1; + copy.indexCount = this.indices != null ? this.indices.length : 0; + copy.uploaded = false; - // 状态标记 - copy.dirty = true; - copy.boundsDirty = true; - copy.bounds = new BoundingBox(); - copy.selected = this.selected; + // 状态标记 + copy.dirty = true; + copy.boundsDirty = true; + copy.bounds = new BoundingBox(); + copy.selected = this.selected; - return copy; - } + return copy; + } public int getVaoId() { @@ -1046,4 +1108,4 @@ public class Mesh2D { sb.append('}'); return sb.toString(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java b/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java index 92564ef..8a0b80c 100644 --- a/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java +++ b/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java @@ -3,6 +3,7 @@ package com.chuangzhou.vivid2D.test; import com.chuangzhou.vivid2D.render.awt.ModelClickListener; import com.chuangzhou.vivid2D.render.awt.ModelLayerPanel; import com.chuangzhou.vivid2D.render.awt.ModelRenderPanel; +import com.chuangzhou.vivid2D.render.awt.TransformPanel; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.chuangzhou.vivid2D.render.model.util.Mesh2D; @@ -30,20 +31,13 @@ public class ModelLayerPanelTest { } // 创建 UI - JFrame frame = new JFrame("ModelLayerPanel 测试(含渲染面板)"); + JFrame frame = new JFrame("ModelLayerPanel 测试(含渲染面板和变换面板)"); frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); frame.setLayout(new BorderLayout()); // 左侧:图层面板(传入 renderPanel 后可在面板中绑定贴图到 GL 上下文) // 先创建一个占位 renderPanel,再把它传给 layerPanel(ModelRenderPanel 构造需要尺寸) ModelRenderPanel renderPanel = new ModelRenderPanel(model, 640, 480); - //renderPanel.addModelClickListener(new ModelClickListener() { - // @Override - // public void onModelClicked(Mesh2D mesh, float modelX, float modelY, int screenX, int screenY) { - // if (mesh == null) return; - // System.out.println("点击了模型:" + mesh.getName() + ",模型坐标:" + modelX + ", " + modelY + ",屏幕坐标:" + screenX + ", " + screenY); - // } - //}); ModelLayerPanel layerPanel = new ModelLayerPanel(model, renderPanel); layerPanel.setPreferredSize(new Dimension(260, 600)); frame.add(layerPanel, BorderLayout.WEST); @@ -52,11 +46,25 @@ public class ModelLayerPanelTest { renderPanel.setPreferredSize(new Dimension(640, 480)); frame.add(renderPanel, BorderLayout.CENTER); - // 右侧:显示模型树(用于观察当前模型部件结构) + // 创建变换面板 + TransformPanel transformPanel = new TransformPanel(renderPanel); + + // 右侧:创建选项卡面板,包含模型树和变换面板 + JTabbedPane rightTabbedPane = new JTabbedPane(); + + // 模型树选项卡 JTree tree = new JTree(model.toTreeNode()); JScrollPane treeScroll = new JScrollPane(tree); - treeScroll.setPreferredSize(new Dimension(240, 600)); - frame.add(treeScroll, BorderLayout.EAST); + treeScroll.setPreferredSize(new Dimension(280, 600)); + rightTabbedPane.addTab("模型结构", treeScroll); + + // 变换面板选项卡 + JScrollPane transformScroll = new JScrollPane(transformPanel); + transformScroll.setPreferredSize(new Dimension(280, 600)); + rightTabbedPane.addTab("变换控制", transformScroll); + + rightTabbedPane.setPreferredSize(new Dimension(300, 600)); + frame.add(rightTabbedPane, BorderLayout.EAST); // 底部:演示按钮(刷新树以反映面板中对模型的更改) JPanel bottom = new JPanel(new FlowLayout(FlowLayout.LEFT)); @@ -80,8 +88,39 @@ public class ModelLayerPanelTest { }); bottom.add(printOrderBtn); + // 添加选中部件更新按钮 + JButton updateSelectionBtn = new JButton("更新选中部件"); + updateSelectionBtn.addActionListener(e -> { + renderPanel.executeInGLContext(() -> { + ModelPart selectedPart = renderPanel.getSelectedPart(); + transformPanel.setSelectedPart(selectedPart); + if (selectedPart != null) { + System.out.println("已选中部件: " + selectedPart.getName()); + } else { + System.out.println("未选中任何部件"); + } + }); + }); + bottom.add(updateSelectionBtn); + frame.add(bottom, BorderLayout.SOUTH); + // 添加模型点击监听器,自动更新变换面板的选中部件 + renderPanel.addModelClickListener(new ModelClickListener() { + @Override + public void onModelClicked(Mesh2D mesh, float modelX, float modelY, int screenX, int screenY) { + if (mesh == null) return; + System.out.println("点击了模型:" + mesh.getName() + ",模型坐标:" + modelX + ", " + modelY + ",屏幕坐标:" + screenX + ", " + screenY); + + // 自动更新变换面板的选中部件 + ModelPart selectedPart = renderPanel.getSelectedPart(); + transformPanel.setSelectedPart(selectedPart); + + // 切换到变换控制选项卡 + rightTabbedPane.setSelectedIndex(1); + } + }); + // 监听窗口关闭,确保释放 GL 资源 frame.addWindowListener(new java.awt.event.WindowAdapter() { @Override @@ -104,9 +143,9 @@ public class ModelLayerPanelTest { } }); - frame.setSize(1200, 700); + frame.setSize(1300, 700); frame.setLocationRelativeTo(null); frame.setVisible(true); }); } -} +} \ No newline at end of file