diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeEditorDialog.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeEditorDialog.java new file mode 100644 index 0000000..6e7f4ad --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeEditorDialog.java @@ -0,0 +1,677 @@ +package com.chuangzhou.vivid2D.render.awt; + +import com.chuangzhou.vivid2D.render.model.AnimationParameter; + +import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.EmptyBorder; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.TableCellRenderer; +import java.awt.*; +import java.awt.event.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.SortedSet; +import java.util.TreeSet; + +public class KeyframeEditorDialog extends JDialog { + + // --- 现代UI颜色定义 --- + private static final Color COLOR_BACKGROUND = new Color(50, 50, 50); + private static final Color COLOR_FOREGROUND = new Color(220, 220, 220); + private static final Color COLOR_HEADER = new Color(70, 70, 70); + private static final Color COLOR_ACCENT_1 = new Color(230, 80, 80); // 关键帧 (红色) + private static final Color COLOR_ACCENT_2 = new Color(80, 150, 230); // 当前值 (蓝色) + private static final Color COLOR_GRID = new Color(60, 60, 60); + private static final Border DIALOG_PADDING = new EmptyBorder(10, 10, 10, 10); + + private final AnimationParameter parameter; + private final EditorRuler ruler; + private final KeyframeTableModel tableModel; + private final JTable keyframeTable; + private final JTextField addField = new JTextField(8); + + /** + * 临时存储编辑,直到用户点击 "OK" + */ + private final TreeSet tempKeyframes; + + // 用于跟踪 OK/Cancel 状态 + private boolean confirmed = false; + + // 内部类,用于显示和编辑的标尺 + private class EditorRuler extends JComponent { + private static final int RULER_HEIGHT = 25; + private static final int MARKER_SIZE = 8; + private static final int TICK_HEIGHT = 5; + private static final int PADDING = 15; // 左右内边距 + private static final int LABEL_VMARGIN = 3; // 标签垂直边距 + + // --- 用于跟踪鼠标悬浮 --- + private int mouseHoverX = -1; + private float mouseHoverValue = 0.0f; + // -------------------------- + + EditorRuler() { + setPreferredSize(new Dimension(100, RULER_HEIGHT + 35)); + setBackground(COLOR_BACKGROUND); + setForeground(COLOR_FOREGROUND); + setOpaque(true); + + addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + float value = xToValue(e.getX()); + float range = parameter.getMaxValue() - parameter.getMinValue(); + float snapThresholdPx = 4; // 4 像素 + float snapThreshold = (range > 0) ? (xToValue(getPadding() + (int)snapThresholdPx) - xToValue(getPadding())) : 0; + + Float nearest = getNearestTempKeyframe(value, snapThreshold); + + if (e.isShiftDown() || SwingUtilities.isRightMouseButton(e)) { // 按住 Shift 或右键删除 + if (nearest != null) { + tempKeyframes.remove(nearest); + } + } else if (SwingUtilities.isLeftMouseButton(e)) { + if (nearest != null) { + // 选中已有的 + int row = tableModel.getRowForValue(nearest); + if (row != -1) { + keyframeTable.setRowSelectionInterval(row, row); + keyframeTable.scrollRectToVisible(keyframeTable.getCellRect(row, 0, true)); + } + } else { + // 添加新的 (钳位) + float clampedValue = Math.max(parameter.getMinValue(), Math.min(parameter.getMaxValue(), value)); + tempKeyframes.add(clampedValue); + } + } + updateAllUI(); + } + + // --- [修复] mouseExited 移到这里 (MouseAdapter) --- + @Override + public void mouseExited(MouseEvent e) { + mouseHoverX = -1; // 鼠标离开,清除悬浮位置 + repaint(); // 触发重绘以隐藏提示 + } + // ---------------------------------------------------- + }); + + // --- 鼠标移动监听器 --- + addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + mouseHoverX = e.getX(); + mouseHoverValue = xToValue(mouseHoverX); + repaint(); // 触发重绘以显示悬浮提示 + } + // 注意:这里不再需要 @Override public void mouseExited(MouseEvent e) + }); + // ---------------------------------- + } + + private int getPadding() { + return PADDING; + } + + private float xToValue(int x) { + int padding = getPadding(); + int trackWidth = getWidth() - padding * 2; + if (trackWidth <= 0) return parameter.getMinValue(); + + float percent = Math.max(0f, Math.min(1f, (float) (x - padding) / trackWidth)); + return parameter.getMinValue() + percent * (parameter.getMaxValue() - parameter.getMinValue()); + } + + private int valueToX(float value) { + int padding = getPadding(); + int trackWidth = getWidth() - padding * 2; + float range = parameter.getMaxValue() - parameter.getMinValue(); + float percent = 0; + if (range > 0) { + // [修复] 确保钳位 + percent = (Math.max(parameter.getMinValue(), Math.min(parameter.getMaxValue(), value)) - parameter.getMinValue()) / range; + } + return padding + (int) (percent * trackWidth); + } + + private Float getNearestTempKeyframe(float value, float snapThreshold) { + if (snapThreshold <= 0) return null; + + Float lower = tempKeyframes.floor(value); + Float higher = tempKeyframes.ceiling(value); + + float distToLower = (lower != null) ? Math.abs(value - lower) : Float.MAX_VALUE; + float distToHigher = (higher != null) ? Math.abs(value - higher) : Float.MAX_VALUE; + + if (distToLower < snapThreshold && distToLower <= distToHigher) { + return lower; + } + if (distToHigher < snapThreshold && distToHigher < distToLower) { + return higher; + } + return null; + } + + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); // 绘制不透明背景 + Graphics2D g2 = (Graphics2D) g.create(); // 使用 g.create() 防止 g 被修改 + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // [修复] 强制清除背景,防止渲染残留 + g2.setColor(getBackground()); + g2.fillRect(0, 0, getWidth(), getHeight()); + + int padding = getPadding(); + int w = getWidth() - padding * 2; + int h = getHeight(); + + int topOffset = 15; + int trackY = topOffset + (h - topOffset) / 2; // 垂直居中于剩余空间 + + // 1. 轨道 + g2.setColor(COLOR_GRID); + g2.setStroke(new BasicStroke(2)); + g2.drawLine(padding, trackY, padding + w, trackY); + + // 2. 绘制刻度和标签 (min, max, mid) + float min = parameter.getMinValue(); + float max = parameter.getMaxValue(); + + drawTick(g2, min, trackY, true); // 强制绘制 min + drawTick(g2, max, trackY, true); // 强制绘制 max + + // 仅在 min 和 max 不太近时绘制 mid + if (Math.abs(max-min) > 0.1) { + float mid = min + (max - min) / 2; + drawTick(g2, mid, trackY, false); // 不强制绘制 mid + } + + // 3. 绘制关键帧 (来自 tempKeyframes) + g2.setColor(COLOR_ACCENT_1); + for (float f : tempKeyframes) { + int x = valueToX(f); + g2.fillOval(x - MARKER_SIZE / 2, trackY - MARKER_SIZE / 2, MARKER_SIZE, MARKER_SIZE); + } + + // --- 4. 绘制鼠标悬浮值 (Hover Value) --- + if (mouseHoverX != -1) { + if (mouseHoverX >= padding && mouseHoverX <= padding + w) { + g2.setColor(COLOR_ACCENT_1); + g2.drawLine(mouseHoverX, trackY - TICK_HEIGHT - 2, mouseHoverX, trackY + TICK_HEIGHT + 2); + String hoverLabel = String.format("%.2f", mouseHoverValue); + FontMetrics fm = g2.getFontMetrics(); + int labelWidth = fm.stringWidth(hoverLabel); + int labelY = topOffset + (RULER_HEIGHT / 2) - fm.getAscent() / 2; + int labelX = mouseHoverX - labelWidth / 2; + labelX = Math.max(padding, Math.min(labelX, getWidth() - padding - labelWidth)); + g2.drawString(hoverLabel, labelX, labelY); + } + } + // --------------------------------------------- + + // 5. 绘制当前值 (来自 parameter) + g2.setColor(COLOR_ACCENT_2); + int x = valueToX(parameter.getValue()); + g2.fillOval(x - MARKER_SIZE / 2, trackY - MARKER_SIZE / 2, MARKER_SIZE, MARKER_SIZE); + + g2.dispose(); // 释放 g.create() 创建的 Graphics + } + + private void drawTick(Graphics2D g2, float value, int y, boolean forceLabel) { + int x = valueToX(value); + g2.setColor(COLOR_GRID.brighter()); + g2.drawLine(x, y - TICK_HEIGHT, x, y + TICK_HEIGHT); + + // [修复] 仅在空间足够或被强制时绘制标签 + FontMetrics fm = g2.getFontMetrics(); + // [修复] 格式化为 2 位小数 + String label = String.format("%.2f", value); + int labelWidth = fm.stringWidth(label); + + // 简单的防重叠 + boolean fits = (labelWidth < (getWidth() - getPadding()*2) / 5); + + if (forceLabel || fits) { + g2.setColor(COLOR_FOREGROUND); + g2.drawString(label, x - labelWidth / 2, y + TICK_HEIGHT + fm.getAscent() + LABEL_VMARGIN); + } + } + } + + /** + * 自定义 TableModel 以显示 "No." 和 "Value" + */ + private class KeyframeTableModel extends AbstractTableModel { + private final String[] columnNames = {"序", "值"}; + private java.util.List keyframes = new ArrayList<>(); + + public void setData(SortedSet data) { + this.keyframes.clear(); + this.keyframes.addAll(data); + fireTableDataChanged(); + } + + public int getRowForValue(float value) { + int index = Collections.binarySearch(keyframes, value); + return (index < 0) ? -1 : index; + } + + public Float getValueAtRow(int row) { + if (row >= 0 && row < keyframes.size()) { + return keyframes.get(row); + } + return null; + } + + @Override + public int getRowCount() { + return keyframes.size(); + } + + @Override + public int getColumnCount() { + return columnNames.length; + } + + @Override + public String getColumnName(int column) { + return columnNames[column]; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + if (columnIndex == 0) { + return rowIndex + 1; // "No" (序号) + } + if (columnIndex == 1) { + return keyframes.get(rowIndex); // "Value" (值) - [修复] 返回 Float 对象 + } + return null; + } + + // --- 允许 "值" 列被编辑 --- + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return columnIndex == 1; // 只有 "值" 列可以编辑 + } + + // --- 处理单元格编辑后的值 --- + @Override + public void setValueAt(Object aValue, int rowIndex, int columnIndex) { + if (columnIndex != 1) return; + + float newValue; + try { + newValue = Float.parseFloat(aValue.toString()); + } catch (NumberFormatException e) { + JOptionPane.showMessageDialog(KeyframeEditorDialog.this, + "请输入有效的浮点数", "格式错误", JOptionPane.ERROR_MESSAGE); + return; + } + + // 钳位 + newValue = Math.max(parameter.getMinValue(), Math.min(parameter.getMaxValue(), newValue)); + + // 获取旧值 + Float oldValue = getValueAtRow(rowIndex); + if (oldValue == null) return; + + // 更新临时的 Set + tempKeyframes.remove(oldValue); + tempKeyframes.add(newValue); + + // 彻底刷新UI (因为 Set 排序可能已改变) + updateAllUI(); + + // 刷新后,重新定位并选中新值的行 + int newRow = tableModel.getRowForValue(newValue); + if (newRow != -1) { + keyframeTable.setRowSelectionInterval(newRow, newRow); + } + } + // ------------------------------------ + + @Override + public Class getColumnClass(int columnIndex) { + if (columnIndex == 0) return Integer.class; + if (columnIndex == 1) return Float.class; + return Object.class; + } + } + + + public KeyframeEditorDialog(Window owner, AnimationParameter parameter) { + super(owner, "关键帧编辑器: " + parameter.getId(), ModalityType.APPLICATION_MODAL); + this.parameter = parameter; + + this.tempKeyframes = new TreeSet<>(parameter.getKeyframes()); + + this.ruler = new EditorRuler(); + this.tableModel = new KeyframeTableModel(); + this.keyframeTable = new JTable(tableModel); + + initUI(); + updateAllUI(); + } + + private void initUI() { + setSize(500, 400); + setMinimumSize(new Dimension(450, 350)); + setResizable(true); + + setLocationRelativeTo(getOwner()); + getContentPane().setBackground(COLOR_BACKGROUND); + setLayout(new BorderLayout(5, 5)); + ((JPanel) getContentPane()).setBorder(DIALOG_PADDING); + + // 1. 顶部标尺 + ruler.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(COLOR_GRID), "标尺 (点击添加, Shift/右键 删除)", + 0, 0, getFont(), COLOR_FOREGROUND + )); + add(ruler, BorderLayout.NORTH); + + // 2. 中间列表 + configureTableAppearance(); + JScrollPane scroll = new JScrollPane(keyframeTable); + configureScrollPaneAppearance(scroll); + + JPanel centerPanel = new JPanel(new BorderLayout(5, 5)); + centerPanel.setBackground(COLOR_BACKGROUND); + centerPanel.add(scroll, BorderLayout.CENTER); + centerPanel.add(createListActionsPanel(), BorderLayout.EAST); + + add(centerPanel, BorderLayout.CENTER); + + // 3. 底部操作栏 (OK/Cancel) + add(createBottomPanel(), BorderLayout.SOUTH); + + // --- 为 JTable 添加双击监听器 --- + keyframeTable.addMouseListener(new MouseAdapter() { + public void mousePressed(MouseEvent e) { + if (e.getClickCount() == 2) { + int row = keyframeTable.rowAtPoint(e.getPoint()); + int col = keyframeTable.columnAtPoint(e.getPoint()); + // 确保是 "值" 列 (索引 1) + if (row >= 0 && col == 1) { + // 启动编辑 + if (keyframeTable.editCellAt(row, col)) { + // 尝试选中编辑器中的文本 + Component editor = keyframeTable.getEditorComponent(); + if (editor instanceof JTextField) { + ((JTextField)editor).selectAll(); + } + } + } + } + } + }); + // ------------------------------------ + } + + private JPanel createListActionsPanel() { + JPanel actionsPanel = new JPanel(); + actionsPanel.setBackground(COLOR_BACKGROUND); + actionsPanel.setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(0, 5, 0, 5); + gbc.anchor = GridBagConstraints.NORTH; + gbc.weighty = 1.0; + + JButton delButton = new JButton("删除"); + styleButton(delButton); + delButton.addActionListener(e -> removeSelectedKeyframe()); + + actionsPanel.add(delButton, gbc); + + return actionsPanel; + } + + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new BorderLayout(5, 5)); + bottomPanel.setBackground(COLOR_BACKGROUND); + + // 左侧:添加新帧 + JPanel addPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0)); + addPanel.setBackground(COLOR_BACKGROUND); + + JLabel addLabel = new JLabel("添加值:"); + addLabel.setForeground(COLOR_FOREGROUND); + styleTextField(addField); + + JButton addButton = new JButton("添加"); + styleButton(addButton); + + addPanel.add(addLabel); + addPanel.add(addField); + addPanel.add(addButton); + + // 右侧:OK / Cancel + JPanel okCancelPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 0)); + okCancelPanel.setBackground(COLOR_BACKGROUND); + + JButton okButton = new JButton("确定"); + JButton cancelButton = new JButton("取消"); + styleButton(okButton); + styleButton(cancelButton); + + okCancelPanel.add(okButton); + okCancelPanel.add(cancelButton); + + bottomPanel.add(addPanel, BorderLayout.CENTER); + bottomPanel.add(okCancelPanel, BorderLayout.EAST); + + // 事件绑定 + addButton.addActionListener(e -> addKeyframeFromField()); + addField.addActionListener(e -> addKeyframeFromField()); + + okButton.addActionListener(e -> onOK()); + cancelButton.addActionListener(e -> onCancel()); + + // Esc 键关闭 = Cancel + getRootPane().registerKeyboardAction(e -> onCancel(), + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + JComponent.WHEN_IN_FOCUSED_WINDOW); + + return bottomPanel; + } + + /** + * 确认更改,应用到原始 parameter + * [修复] 恢复为 private 访问权限,因为它不覆盖任何父类方法。 + */ + private void onOK() { + // 停止任何可能的单元格编辑 + if (keyframeTable.isEditing()) { + keyframeTable.getCellEditor().stopCellEditing(); + } + + parameter.clearKeyframes(); + for (Float f : tempKeyframes) { + parameter.addKeyframe(f); + } + this.confirmed = true; // 标记为已确认 + dispose(); + } + + /** + * 取消更改 + * [修复] 恢复为 private 访问权限,因为它不覆盖任何父类方法。 + */ + private void onCancel() { + // 停止任何可能的单元格编辑,但丢弃结果 + if (keyframeTable.isEditing()) { + keyframeTable.getCellEditor().cancelCellEditing(); + } + + this.confirmed = false; // 标记为未确认 + dispose(); + } + + private void addKeyframeFromField() { + try { + float val = Float.parseFloat(addField.getText().trim()); + // 钳位 + val = Math.max(parameter.getMinValue(), Math.min(parameter.getMaxValue(), val)); + + tempKeyframes.add(val); + updateAllUI(); + + // 添加后自动选中 + int row = tableModel.getRowForValue(val); + if (row != -1) { + keyframeTable.setRowSelectionInterval(row, row); + keyframeTable.scrollRectToVisible(keyframeTable.getCellRect(row, 0, true)); + } + + addField.setText(""); + } catch (NumberFormatException e) { + JOptionPane.showMessageDialog(this, "无效的数值", "错误", JOptionPane.ERROR_MESSAGE); + } + } + + private void removeSelectedKeyframe() { + int selectedRow = keyframeTable.getSelectedRow(); + if (selectedRow != -1) { + Float val = tableModel.getValueAtRow(selectedRow); + if (val != null) { + tempKeyframes.remove(val); + updateAllUI(); + + // 重新选中删除后的下一行 + if (tableModel.getRowCount() > 0) { + int newSel = Math.min(selectedRow, tableModel.getRowCount() - 1); + keyframeTable.setRowSelectionInterval(newSel, newSel); + } + } + } + } + + private void updateAllUI() { + // 更新列表 + tableModel.setData(tempKeyframes); + // 重绘标尺 + ruler.repaint(); + } + + // --- 辅助方法:设置UI风格 --- + + private void configureTableAppearance() { + keyframeTable.setBackground(COLOR_BACKGROUND); + keyframeTable.setForeground(COLOR_FOREGROUND); + keyframeTable.setGridColor(COLOR_GRID); + keyframeTable.setSelectionBackground(COLOR_ACCENT_2); + keyframeTable.setSelectionForeground(Color.WHITE); + keyframeTable.getTableHeader().setBackground(COLOR_HEADER); + keyframeTable.getTableHeader().setForeground(COLOR_FOREGROUND); + keyframeTable.setFont(getFont().deriveFont(14f)); + keyframeTable.setRowHeight(20); + + // 居中 "No" 列 + DefaultTableCellRenderer centerRenderer = new DefaultTableCellRenderer(); + centerRenderer.setHorizontalAlignment(JLabel.CENTER); + centerRenderer.setBackground(COLOR_BACKGROUND); + centerRenderer.setForeground(COLOR_FOREGROUND); + keyframeTable.getColumnModel().getColumn(0).setMaxWidth(60); + keyframeTable.getColumnModel().getColumn(0).setCellRenderer(centerRenderer); + + // "Value" 列,格式化浮点数 + TableCellRenderer floatRenderer = new DefaultTableCellRenderer() { + { // Instance initializer + setHorizontalAlignment(JLabel.RIGHT); // 数字右对齐 + setBorder(new EmptyBorder(0, 5, 0, 5)); // 增加内边距 + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { + super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column); + + if (value instanceof Float) { + setText(String.format("%.6f", (Float) value)); + } + + if (!isSelected) { + setBackground(COLOR_BACKGROUND); + setForeground(COLOR_FOREGROUND); + } + return this; + } + }; + keyframeTable.getColumnModel().getColumn(1).setCellRenderer(floatRenderer); + + // 为 "值" 列设置一个暗黑风格的编辑器 + JTextField editorTextField = new JTextField(); + styleTextField(editorTextField); // 复用暗黑风格 + editorTextField.setBorder(BorderFactory.createLineBorder(COLOR_ACCENT_2)); // 编辑时高亮 + keyframeTable.getColumnModel().getColumn(1).setCellEditor(new DefaultCellEditor(editorTextField)); + } + + private void configureScrollPaneAppearance(JScrollPane scroll) { + scroll.setBackground(COLOR_BACKGROUND); + scroll.getViewport().setBackground(COLOR_BACKGROUND); + scroll.setBorder(BorderFactory.createLineBorder(COLOR_GRID)); + scroll.getVerticalScrollBar().setUI(new javax.swing.plaf.basic.BasicScrollBarUI() { + @Override + protected void configureScrollBarColors() { + this.thumbColor = COLOR_HEADER; + this.trackColor = COLOR_BACKGROUND; + } + + @Override + protected JButton createDecreaseButton(int orientation) { + return createZeroButton(); + } + + @Override + protected JButton createIncreaseButton(int orientation) { + return createZeroButton(); + } + + private JButton createZeroButton() { + JButton b = new JButton(); + b.setPreferredSize(new Dimension(0, 0)); + return b; + } + }); + } + + private void styleButton(JButton button) { + button.setBackground(COLOR_HEADER); + button.setForeground(COLOR_FOREGROUND); + button.setFocusPainted(false); + button.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(COLOR_GRID), + new EmptyBorder(5, 10, 5, 10) + )); + } + + private void styleTextField(JTextField field) { + field.setBackground(COLOR_HEADER); + field.setForeground(COLOR_FOREGROUND); + field.setCaretColor(COLOR_FOREGROUND); + field.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(COLOR_GRID), + new EmptyBorder(4, 4, 4, 4) + )); + } + + + public boolean isConfirmed() { + return confirmed; + } + + /** + * 显示对话框。 + */ + public static boolean showEditor(Window owner, AnimationParameter parameter) { + if (parameter == null) return false; + KeyframeEditorDialog dialog = new KeyframeEditorDialog(owner, parameter); + dialog.setVisible(true); + return dialog.isConfirmed(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeSlider.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeSlider.java new file mode 100644 index 0000000..96e96b8 --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/KeyframeSlider.java @@ -0,0 +1,168 @@ +package com.chuangzhou.vivid2D.render.awt; + +import com.chuangzhou.vivid2D.render.model.AnimationParameter; + +import javax.swing.*; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.util.ArrayList; +import java.util.List; + +/** + * 一个自定义的滑块控件,用于显示和操作 AnimationParameter。 + * 它可以显示关键帧标记,并实现拖拽时的吸附功能。 + */ +public class KeyframeSlider extends JComponent { + + private static final int TRACK_HEIGHT = 6; + private static final int THUMB_SIZE = 14; + private static final int KEYFRAME_MARKER_SIZE = 8; + private static final int PADDING = 8; + + private AnimationParameter parameter; + private boolean isDragging = false; + private final List listeners = new ArrayList<>(); + + // 吸附阈值(占总宽度的百分比) + private final float snapThresholdPercent = 0.02f; // 2% + + public KeyframeSlider() { + setPreferredSize(new Dimension(100, THUMB_SIZE + PADDING * 2)); + setMinimumSize(new Dimension(50, THUMB_SIZE + PADDING * 2)); + + addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + if (parameter == null) return; + isDragging = true; + updateValueFromMouse(e.getX()); + } + + @Override + public void mouseReleased(MouseEvent e) { + isDragging = false; + } + }); + + addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseDragged(MouseEvent e) { + if (isDragging && parameter != null) { + updateValueFromMouse(e.getX()); + } + } + }); + } + + /** + * 设置此滑块绑定的参数。 + */ + public void setParameter(AnimationParameter p) { + this.parameter = p; + repaint(); + } + + public AnimationParameter getParameter() { + return parameter; + } + + private void updateValueFromMouse(int mouseX) { + if (parameter == null) return; + + int trackStart = PADDING; + int trackWidth = getWidth() - PADDING * 2; + float percent = Math.max(0f, Math.min(1f, (float) (mouseX - trackStart) / trackWidth)); + + float min = parameter.getMinValue(); + float max = parameter.getMaxValue(); + float range = max - min; + float newValue = min + percent * range; + + // --- 吸附逻辑 --- + float snapThreshold = range * snapThresholdPercent; + Float nearestKeyframe = parameter.getNearestKeyframe(newValue, snapThreshold); + if (nearestKeyframe != null) { + newValue = nearestKeyframe; + } + // ---------------- + + if (parameter.getValue() != newValue) { + parameter.setValue(newValue); // setValue 会自动钳位 + fireStateChanged(); + repaint(); + } + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g; + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + if (parameter == null) { + paintDisabled(g2); + return; + } + + int trackY = (getHeight() - TRACK_HEIGHT) / 2; + int trackStart = PADDING; + int trackWidth = getWidth() - PADDING * 2; + + // 1. 绘制轨道 + g2.setColor(getBackground().darker()); + g2.fillRoundRect(trackStart, trackY, trackWidth, TRACK_HEIGHT, TRACK_HEIGHT, TRACK_HEIGHT); + + // 2. 绘制关键帧标记 + g2.setColor(Color.CYAN.darker()); + int markerY = (getHeight() - KEYFRAME_MARKER_SIZE) / 2; + for (float keyframeValue : parameter.getKeyframes()) { + float percent = (keyframeValue - parameter.getMinValue()) / (parameter.getMaxValue() - parameter.getMinValue()); + int markerX = trackStart + (int) (percent * trackWidth) - KEYFRAME_MARKER_SIZE / 2; + // 绘制菱形 + Polygon diamond = new Polygon(); + diamond.addPoint(markerX + KEYFRAME_MARKER_SIZE / 2, markerY); + diamond.addPoint(markerX + KEYFRAME_MARKER_SIZE, markerY + KEYFRAME_MARKER_SIZE / 2); + diamond.addPoint(markerX + KEYFRAME_MARKER_SIZE / 2, markerY + KEYFRAME_MARKER_SIZE); + diamond.addPoint(markerX, markerY + KEYFRAME_MARKER_SIZE / 2); + g2.fill(diamond); + } + + // 3. 绘制滑块 (Thumb) + float currentPercent = parameter.getNormalizedValue(); + int thumbX = trackStart + (int) (currentPercent * trackWidth) - THUMB_SIZE / 2; + int thumbY = (getHeight() - THUMB_SIZE) / 2; + + g2.setColor(isEnabled() ? getForeground() : Color.GRAY); + g2.fillOval(thumbX, thumbY, THUMB_SIZE, THUMB_SIZE); + g2.setColor(getBackground()); + g2.drawOval(thumbX, thumbY, THUMB_SIZE, THUMB_SIZE); + } + + private void paintDisabled(Graphics2D g2) { + int trackY = (getHeight() - TRACK_HEIGHT) / 2; + int trackStart = PADDING; + int trackWidth = getWidth() - PADDING * 2; + g2.setColor(Color.GRAY.brighter()); + g2.fillRoundRect(trackStart, trackY, trackWidth, TRACK_HEIGHT, TRACK_HEIGHT, TRACK_HEIGHT); + } + + // --- ChangeEvent 支持 (与 JSlider 保持一致) --- + public void addChangeListener(ChangeListener l) { + listeners.add(l); + } + + public void removeChangeListener(ChangeListener l) { + listeners.remove(l); + } + + protected void fireStateChanged() { + ChangeEvent e = new ChangeEvent(this); + for (ChangeListener l : new ArrayList<>(listeners)) { + l.stateChanged(e); + } + } +} \ 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 dcddf49..c802156 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ModelRenderPanel.java @@ -7,7 +7,9 @@ import com.chuangzhou.vivid2D.render.awt.tools.SelectionTool; import com.chuangzhou.vivid2D.render.awt.tools.Tool; import com.chuangzhou.vivid2D.render.awt.tools.VertexDeformationTool; import com.chuangzhou.vivid2D.render.awt.tools.LiquifyTool; +import com.chuangzhou.vivid2D.render.awt.util.FrameInterpolator; import com.chuangzhou.vivid2D.render.awt.util.OperationHistoryGlobal; +import com.chuangzhou.vivid2D.render.model.AnimationParameter; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.chuangzhou.vivid2D.render.model.util.Mesh2D; @@ -53,6 +55,7 @@ public class ModelRenderPanel extends JPanel { private final CopyOnWriteArrayList clickListeners = new CopyOnWriteArrayList<>(); private final StatusRecordManagement statusRecordManagement; private final RanderToolsManager randerToolsManager = RanderToolsManager.getInstance(); + private final AtomicReference parametersManagement = new AtomicReference<>(); public static final float BORDER_THICKNESS = 6.0f; public static final float CORNER_SIZE = 12.0f; // ================== 摄像机控制相关字段 ================== @@ -90,8 +93,8 @@ public class ModelRenderPanel extends JPanel { this.toolManagement = new ToolManagement(this, randerToolsManager); // 注册所有工具 - toolManagement.registerTool(new PuppetDeformationTool(this),new PuppetDeformationRander()); - toolManagement.registerTool(new VertexDeformationTool(this),new VertexDeformationRander()); + toolManagement.registerTool(new PuppetDeformationTool(this), new PuppetDeformationRander()); + toolManagement.registerTool(new VertexDeformationTool(this), new VertexDeformationRander()); toolManagement.registerTool(new LiquifyTool(this), new LiquifyTargetPartRander()); initialize(); @@ -103,6 +106,35 @@ public class ModelRenderPanel extends JPanel { doubleClickTimer.setRepeats(false); modelsUpdate(getModel()); + + ParametersPanel.ParameterEventBroadcaster.getInstance().addListener(new ParametersPanel.ParameterEventListener() { + @Override + public void onParameterUpdated(AnimationParameter p) { + // 1. 获取参数管理器 + ParametersManagement pm = getParametersManagement(); + if (pm == null) { + logger.warn("ParametersManagement 未初始化,无法应用参数更新。"); + return; + } + final List selectedParts = getSelectedParts(); + if (selectedParts.isEmpty()) { + logger.debug("没有选中的模型部件,跳过应用参数。"); + return; + } + + // 必须在 GL 上下文线程中执行模型操作 + glContextManager.executeInGLContext(() -> { + try { + FrameInterpolator.applyFrameInterpolations(pm, selectedParts, p, logger); + for (ModelPart selectedPart : selectedParts) { + selectedPart.updateMeshVertices(); + } + } catch (Exception ex) { + logger.error("在GL上下文线程中应用关键字动画参数时出错", ex); + } + }); + } + }); } /** @@ -184,7 +216,6 @@ public class ModelRenderPanel extends JPanel { * 获取当前选中的网格 */ public Mesh2D getSelectedMesh() { - // 委托给工具管理系统的当前工具 Tool currentTool = toolManagement.getCurrentTool(); if (currentTool instanceof SelectionTool) { return ((SelectionTool) currentTool).getSelectedMesh(); @@ -662,6 +693,21 @@ public class ModelRenderPanel extends JPanel { return null; } + /** + * 获取参数管理器 + */ + public ParametersManagement getParametersManagement() { + return parametersManagement.get(); + } + + /** + * 设置参数管理器 + */ + public void setParametersManagement(ParametersManagement parametersManagement) { + this.parametersManagement.set(parametersManagement); + glContextManager.executeInGLContext(() -> ModelRenderPanel.this.parametersManagement.set(parametersManagement)); + } + public enum DragMode { NONE, // 无拖拽 MOVE, // 移动部件 diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/ParametersPanel.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/ParametersPanel.java new file mode 100644 index 0000000..6e350e6 --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/ParametersPanel.java @@ -0,0 +1,605 @@ +package com.chuangzhou.vivid2D.render.awt; + +import com.chuangzhou.vivid2D.render.awt.tools.VertexDeformationTool; +import com.chuangzhou.vivid2D.render.model.AnimationParameter; +import com.chuangzhou.vivid2D.render.model.Model2D; +import com.chuangzhou.vivid2D.render.model.ModelPart; +import com.chuangzhou.vivid2D.render.model.util.Mesh2D; + +import javax.swing.*; +import javax.swing.Timer; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; +import java.awt.*; +import java.awt.event.*; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.*; +import java.util.List; + + +/** + * 窗口参数管理面板(使用你给定的结构) + * 功能: + * - 当没有选中网格时显示“未选择网格”的占位 + * - 当选中网格时,找到对应的 ModelPart,列出其所有 AnimationParameter + * - [!] 使用 KeyframeSlider 替换 JSlider,以显示/吸附关键帧 + * - [!] 双击参数列表项可打开 KeyframeEditorDialog + * - 支持新增、删除、重命名、修改参数值 + * - 广播参数相关事件 + * - 按下 ESC 取消选择并广播取消事件 + */ +public class ParametersPanel extends JPanel { + private final ModelRenderPanel renderPanel; + private final Model2D model; + private AnimationParameter selectParameter; + + // UI + private final CardLayout cardLayout = new CardLayout(); + private final JPanel cardRoot = new JPanel(cardLayout); + + private final DefaultListModel listModel = new DefaultListModel<>(); + private final JList parameterList = new JList<>(listModel); + + private final JButton addBtn = new JButton("新建"); + private final JButton delBtn = new JButton("删除"); + private final JButton renameBtn = new JButton("重命名"); + + // --- 修改:使用 KeyframeSlider 替换 JSlider --- + private final KeyframeSlider valueSlider = new KeyframeSlider(); + // ------------------------------------------ + + private final JLabel valueLabel = new JLabel("值: -"); + private final Timer pollTimer; + + // 当前绑定的 ModelPart(对应选中网格) + private volatile ModelPart currentPart = null; + + public ParametersPanel(ModelRenderPanel renderPanel) { + this.renderPanel = renderPanel; + this.model = renderPanel.getModel(); + + setLayout(new BorderLayout()); + setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6)); + + // emptyPanel + JPanel emptyPanel = new JPanel(new BorderLayout()); + emptyPanel.add(new JLabel("未选择网格", SwingConstants.CENTER), BorderLayout.CENTER); + + // paramPanel 构建 + parameterList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + parameterList.setCellRenderer((list, value, index, isSelected, cellHasFocus) -> { + JLabel l = new JLabel(value == null ? "" : value.getId()); + l.setOpaque(true); + l.setBackground(isSelected ? UIManager.getColor("List.selectionBackground") : UIManager.getColor("List.background")); + l.setForeground(isSelected ? UIManager.getColor("List.selectionForeground") : UIManager.getColor("List.foreground")); + return l; + }); + + JScrollPane scroll = new JScrollPane(parameterList); + scroll.setPreferredSize(new Dimension(260, 160)); + + JPanel topBar = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 6)); + topBar.add(addBtn); + topBar.add(delBtn); + topBar.add(renameBtn); + + JPanel bottomBar = new JPanel(new BorderLayout(6, 6)); + JPanel sliderPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 6, 6)); + sliderPanel.add(new JLabel("参数值:")); + valueSlider.setPreferredSize(new Dimension(150, 25)); // 给自定义滑块一个合适的大小 + sliderPanel.add(valueSlider); + sliderPanel.add(valueLabel); + bottomBar.add(sliderPanel, BorderLayout.CENTER); + + JPanel paramPanel = new JPanel(new BorderLayout(6, 6)); + paramPanel.add(topBar, BorderLayout.NORTH); + paramPanel.add(scroll, BorderLayout.CENTER); + paramPanel.add(bottomBar, BorderLayout.SOUTH); + + cardRoot.add(emptyPanel, "EMPTY"); + cardRoot.add(paramPanel, "PARAM"); + add(cardRoot, BorderLayout.CENTER); + + // 按键 ESC 绑定:取消选择 + setupEscBinding(); + + // 事件绑定 + bindActions(); + + // 定期轮询选中网格(200ms) + pollTimer = new Timer(200, e -> pollSelectedMesh()); + pollTimer.start(); + + // 初次展示 + updateCard(); + } + + private void setupEscBinding() { + InputMap im = getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); + ActionMap am = getActionMap(); + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "cancelSelection"); + am.put("cancelSelection", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + clearSelection(); + ParameterEventBroadcaster.getInstance().fireCancelEvent(); + } + }); + } + + private void bindActions() { + addBtn.addActionListener(e -> onAddParameter()); + delBtn.addActionListener(e -> onDeleteParameter()); + renameBtn.addActionListener(e -> onRenameParameter()); + + parameterList.addListSelectionListener(new ListSelectionListener() { + @Override + public void valueChanged(ListSelectionEvent e) { + if (!e.getValueIsAdjusting()) { + selectParameter = parameterList.getSelectedValue(); + updateSliderForSelected(); + ParameterEventBroadcaster.getInstance().fireSelectEvent(selectParameter); + } + } + }); + + // --- 新增:双击打开关键帧编辑器 --- + parameterList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + AnimationParameter p = parameterList.getSelectedValue(); + if (p != null) { + // 弹出编辑器 + KeyframeEditorDialog.showEditor(SwingUtilities.getWindowAncestor(ParametersPanel.this), p); + // 编辑器关闭后,刷新滑块的显示 + valueSlider.repaint(); + } + } + } + }); + // -------------------------------- + valueSlider.addChangeListener(e -> { + if (selectParameter == null) return; + valueLabel.setText(String.format("%.3f", selectParameter.getValue())); + ParameterEventBroadcaster.getInstance().fireUpdateEvent(selectParameter); + markModelNeedsUpdate(); + }); + } + + private void pollSelectedMesh() { + Mesh2D mesh = getSelectedMesh(); + if (mesh == null) { + if (currentPart != null) { + currentPart = null; + listModel.clear(); + selectParameter = null; + updateCard(); + } + return; + } + ModelPart part = renderPanel.findPartByMesh(mesh); + if (part == null) { + if (currentPart != null) { + currentPart = null; + listModel.clear(); + selectParameter = null; + updateCard(); + } + return; + } + if (currentPart == part) return; // 未变更 + // 切换到新部件 + currentPart = part; + loadParametersFromCurrentPart(); + updateCard(); + } + + private void updateCard() { + if (currentPart == null) { + cardLayout.show(cardRoot, "EMPTY"); + addBtn.setEnabled(false); + delBtn.setEnabled(false); + renameBtn.setEnabled(false); + valueSlider.setEnabled(false); + } else { + cardLayout.show(cardRoot, "PARAM"); + addBtn.setEnabled(true); + delBtn.setEnabled(!listModel.isEmpty()); + renameBtn.setEnabled(!listModel.isEmpty()); + valueSlider.setEnabled(!listModel.isEmpty() && selectParameter != null); + } + } + + private void loadParametersFromCurrentPart() { + listModel.clear(); + selectParameter = null; + if (currentPart == null) return; + try { + Map map = currentPart.getParameters(); + if (map != null) { + for (AnimationParameter p : map.values()) { + listModel.addElement(p); + } + } + } catch (Exception ex) { + ex.printStackTrace(); + } + if (!listModel.isEmpty()) { + parameterList.setSelectedIndex(0); + } + } + + private void onAddParameter() { + if (currentPart == null) return; + JPanel panel = new JPanel(new GridLayout(4, 2, 4, 4)); + JTextField idField = new JTextField(); + JTextField minField = new JTextField("0.0"); + JTextField maxField = new JTextField("1.0"); + JTextField defField = new JTextField("0.0"); + panel.add(new JLabel("参数ID:")); + panel.add(idField); + panel.add(new JLabel("最小值:")); + panel.add(minField); + panel.add(new JLabel("最大值:")); + panel.add(maxField); + panel.add(new JLabel("默认值:")); + panel.add(defField); + + int res = JOptionPane.showConfirmDialog(this, panel, "新建参数", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + if (res != JOptionPane.OK_OPTION) return; + + String id = idField.getText().trim(); + if (id.isEmpty()) { + JOptionPane.showMessageDialog(this, "参数ID不能为空", "错误", JOptionPane.ERROR_MESSAGE); + return; + } + try { + float min = Float.parseFloat(minField.getText().trim()); + float max = Float.parseFloat(maxField.getText().trim()); + float def = Float.parseFloat(defField.getText().trim()); + + // 使用 ModelPart.createParameter 如果可用 + try { + AnimationParameter newP = currentPart.createParameter(id, min, max, def); + // 如果 createParameter 返回了对象,直接使用;否则通过 getParameter 获取 + if (newP == null) newP = currentPart.getParameter(id); + + // 插入 UI 列表 + if (newP != null) { + listModel.addElement(newP); + parameterList.setSelectedValue(newP, true); + ParameterEventBroadcaster.getInstance().fireAddEvent(newP); + markModelNeedsUpdate(); + } else { + JOptionPane.showMessageDialog(this, "新参数创建失败", "错误", JOptionPane.ERROR_MESSAGE); + } + } catch (NoSuchMethodError | NoClassDefFoundError ignore) { + // 兜底:通过反射直接修改 internal map(风险自负) + try { + Method m = currentPart.getClass().getMethod("createParameter", String.class, float.class, float.class, float.class); + AnimationParameter newP = (AnimationParameter) m.invoke(currentPart, id, min, max, def); + if (newP != null) { + listModel.addElement(newP); + parameterList.setSelectedValue(newP, true); + ParameterEventBroadcaster.getInstance().fireAddEvent(newP); + markModelNeedsUpdate(); + } + } catch (Exception ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "创建参数失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + } + } + } catch (NumberFormatException nfe) { + JOptionPane.showMessageDialog(this, "数值格式错误", "错误", JOptionPane.ERROR_MESSAGE); + } + updateCard(); + } + + private void onDeleteParameter() { + if (currentPart == null) return; + AnimationParameter sel = parameterList.getSelectedValue(); + if (sel == null) return; + int r = JOptionPane.showConfirmDialog(this, "确认删除参数: " + sel.getId() + " ?", "确认删除", JOptionPane.YES_NO_OPTION); + if (r != JOptionPane.YES_OPTION) return; + + try { + Map map = currentPart.getParameters(); + if (map != null) { + map.remove(sel.getId()); + // 如果 ModelPart 提供删除方法可以使用之;此处直接移除 + } else { + // 反射尝试 + Field f = currentPart.getClass().getDeclaredField("parameters"); + f.setAccessible(true); + Object o = f.get(currentPart); + if (o instanceof Map) { + ((Map) o).remove(sel.getId()); + } + } + listModel.removeElement(sel); + selectParameter = null; + ParameterEventBroadcaster.getInstance().fireRemoveEvent(sel); + markModelNeedsUpdate(); + } catch (Exception ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "删除失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + } + updateCard(); + } + + private void onRenameParameter() { + if (currentPart == null) return; + AnimationParameter sel = parameterList.getSelectedValue(); + if (sel == null) return; + String newId = JOptionPane.showInputDialog(this, "输入新 ID:", sel.getId()); + if (newId == null || newId.trim().isEmpty()) return; + newId = newId.trim(); + try { + Map map = currentPart.getParameters(); + if (map != null) { + // 创建新 entry,移除旧 entry,保留值范围和值 + AnimationParameter old = map.remove(sel.getId()); + if (old != null) { + AnimationParameter copy = new AnimationParameter(newId, old.getMinValue(), old.getMaxValue(), old.getValue()); + // 复制关键帧 + old.getKeyframes().forEach(copy::addKeyframe); + map.put(newId, copy); + // 刷新 UI + loadParametersFromCurrentPart(); + ParameterEventBroadcaster.getInstance().fireRenameEvent(old, copy); + markModelNeedsUpdate(); + } + } else { + // 反射处理 + Field f = currentPart.getClass().getDeclaredField("parameters"); + f.setAccessible(true); + Object o = f.get(currentPart); + if (o instanceof Map) { + Map pm = (Map) o; + AnimationParameter old = pm.remove(sel.getId()); + if (old != null) { + AnimationParameter copy = new AnimationParameter(newId, old.getMinValue(), old.getMaxValue(), old.getValue()); + // 复制关键帧 + old.getKeyframes().forEach(copy::addKeyframe); + pm.put(newId, copy); + loadParametersFromCurrentPart(); + ParameterEventBroadcaster.getInstance().fireRenameEvent(old, copy); + markModelNeedsUpdate(); + } + } + } + } catch (Exception ex) { + ex.printStackTrace(); + JOptionPane.showMessageDialog(this, "重命名失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); + } + updateCard(); + } + + + /** + * 修改:更新滑块以显示新选中的参数。 + */ + private void updateSliderForSelected() { + AnimationParameter p = selectParameter; + if (p == null) { + valueSlider.setEnabled(false); + valueSlider.setParameter(null); // 清空滑块的参数 + valueLabel.setText("值: -"); + } else { + valueSlider.setEnabled(true); + valueSlider.setParameter(p); // 将参数设置给自定义滑块 + valueLabel.setText(String.format("%.3f", p.getValue())); + } + valueSlider.repaint(); + } + + private void setParameterValue(AnimationParameter param, float value) { + if (param == null) return; + // 先尝试 param.setValue + try { + Method m = param.getClass().getMethod("setValue", float.class); + m.invoke(param, value); + return; + } catch (Exception ignored) { + } + // 兜底:反射写字段 + try { + Field f = param.getClass().getDeclaredField("value"); + f.setAccessible(true); + f.setFloat(param, value); + } catch (Exception ignored) { + } + + // 如果 ModelPart 有 setParameterValue 方法,调用之以标记 dirty + if (currentPart != null) { + try { + Method m2 = currentPart.getClass().getMethod("setParameterValue", String.class, float.class); + m2.invoke(currentPart, param.getId(), value); + } catch (Exception ignored) { + } + } + } + + private void clearSelection() { + parameterList.clearSelection(); + selectParameter = null; + updateSliderForSelected(); + } + + /** + * 外部可调用,获取当前选中的网格(基于 renderPanel) + */ + private Mesh2D getSelectedMesh() { + if (renderPanel.getSelectedMesh() == null + && renderPanel.getToolManagement().getCurrentTool() instanceof VertexDeformationTool){ + return ((VertexDeformationTool) renderPanel.getToolManagement().getCurrentTool()).getTargetMesh(); + } + return renderPanel.getSelectedMesh(); + } + + public AnimationParameter getSelectParameter() { + return selectParameter; + } + + private void markModelNeedsUpdate() { + try { + if (model == null) return; + Method m = model.getClass().getMethod("markNeedsUpdate"); + m.invoke(model); + } catch (Exception ignored) { + } + } + + public void dispose() { + if (pollTimer != null) pollTimer.stop(); + } + + /** + * 获取当前选中参数上被“选中”的关键帧。 + * “选中”定义为:滑块的当前值正好(或非常接近)一个关键帧的值。 + * + * @param isPreciseCheck 如果为 true,则只有当 currentValue 几乎精确等于关键帧值时才返回; + * 否则允许在 epsilon 阈值内的吸附。 + * @return 如果当前值命中了关键帧,则返回该帧的值;否则返回 null。 + */ + public Float getSelectedKeyframe(boolean isPreciseCheck) { + if (selectParameter == null) { + return null; + } + + float currentValue = selectParameter.getValue(); + float range = selectParameter.getMaxValue() - selectParameter.getMinValue(); + if (range <= 0) return null; + + // 设置吸附/命中阈值,例如范围的 0.5% + // 注意:这个阈值应该和 KeyframeSlider 中的吸附逻辑保持一致 + float epsilon = range * 0.005f; + // 用于判断浮点数是否"相等"的极小值 + final float EQUALITY_TOLERANCE = 1e-5f; + + // 1. 检查是否有精确匹配的关键帧 + Float nearest = selectParameter.getNearestKeyframe(currentValue, epsilon); + if (nearest != null) { + // 检查是否在吸附阈值内 (旧逻辑) + if (Math.abs(currentValue - nearest) <= epsilon) { + + // ------------------------------------------------------------- + // 2. 新增逻辑:精确检查判断 + if (isPreciseCheck) { + // 如果要求精确检查,则只有当它们几乎相等时才返回 + if (Math.abs(currentValue - nearest) <= EQUALITY_TOLERANCE) { + return nearest; + } else { + // 如果不相等,则不认为是“选中”的关键帧 + return null; + } + } + // ------------------------------------------------------------- + + // 3. 原有吸附逻辑 (仅在非精确检查时执行,或精确检查通过时执行) + // 如果差值大于 EQUALITY_TOLERANCE,说明发生了吸附,需要更新参数值 + if (Math.abs(currentValue - nearest) > EQUALITY_TOLERANCE) { + setParameterValue(selectParameter, nearest); + valueLabel.setText(String.format("%.3f", nearest)); + ParameterEventBroadcaster.getInstance().fireUpdateEvent(selectParameter); + } + + // 返回吸附后的值 (或精确匹配的值) + return nearest; + } + } + return null; + } + + + // =================== 简单事件广播器与监听器 (未修改) =================== + + public interface ParameterEventListener { + default void onParameterAdded(AnimationParameter p) {} + default void onParameterRemoved(AnimationParameter p) {} + default void onParameterUpdated(AnimationParameter p) {} + default void onParameterRenamed(AnimationParameter oldP, AnimationParameter newP) {} + default void onParameterSelected(AnimationParameter p) {} + default void onCancelSelection() {} + } + + public static class ParameterEventBroadcaster { + private static final ParameterEventBroadcaster INSTANCE = new ParameterEventBroadcaster(); + private final List listeners = Collections.synchronizedList(new ArrayList<>()); + + public static ParameterEventBroadcaster getInstance() { + return INSTANCE; + } + + public void addListener(ParameterEventListener l) { + if (l == null) return; + listeners.add(l); + } + + public void removeListener(ParameterEventListener l) { + listeners.remove(l); + } + + public void fireAddEvent(AnimationParameter p) { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onParameterAdded(p); } catch (Exception ignored) {} + } + } + }); + } + + public void fireRemoveEvent(AnimationParameter p) { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onParameterRemoved(p); } catch (Exception ignored) {} + } + } + }); + } + + public void fireUpdateEvent(AnimationParameter p) { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onParameterUpdated(p); } catch (Exception ignored) {} + } + } + }); + } + + public void fireRenameEvent(AnimationParameter oldP, AnimationParameter newP) { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onParameterRenamed(oldP, newP); } catch (Exception ignored) {} + } + } + }); + } + + public void fireSelectEvent(AnimationParameter p) { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onParameterSelected(p); } catch (Exception ignored) {} + } + } + }); + } + + public void fireCancelEvent() { + SwingUtilities.invokeLater(() -> { + synchronized (listeners) { + for (ParameterEventListener l : new ArrayList<>(listeners)) { + try { l.onCancelSelection(); } catch (Exception ignored) {} + } + } + }); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/ParametersManagement.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/ParametersManagement.java new file mode 100644 index 0000000..ea26f2c --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/manager/ParametersManagement.java @@ -0,0 +1,249 @@ +package com.chuangzhou.vivid2D.render.awt.manager; + +import com.chuangzhou.vivid2D.render.awt.ParametersPanel; +import com.chuangzhou.vivid2D.render.model.AnimationParameter; +import com.chuangzhou.vivid2D.render.model.ModelPart; + +import java.util.ArrayList; +import java.util.List; + +public class ParametersManagement { + private final ParametersPanel parametersPanel; + private final List oldValues = new ArrayList<>(); + + public ParametersManagement(ParametersPanel parametersPanel) { + this.parametersPanel = parametersPanel; + } + + /** + * 获取ModelPart的所有参数 + * @param modelPart 部件 + * @return 该部件的所有参数 + */ + public Parameter getModelPartParameters(ModelPart modelPart) { + for (Parameter parameter : oldValues) { + if (parameter.modelPart().equals(modelPart)) { + return parameter; + } + } + return null; + } + + /** + * 获取当前选中的帧 + * position List.of(float modelX, float modelY) + * rotate float modelAngle + * @param isPreciseCheck 是否精确检查 + * @return 当前选中的帧 + */ + public Float getSelectedKeyframe(boolean isPreciseCheck) { + return parametersPanel.getSelectedKeyframe(isPreciseCheck); + } + + /** + * 获取当前选中的参数 + * @return 当前选中的参数 + */ + public AnimationParameter getSelectParameter() { + if (parametersPanel.getSelectParameter() == null){ + // System.out.println("getSelectParameter() is null"); + return null; + } + return parametersPanel.getSelectParameter().copy(); + } + + /** + * 监听参数变化 (强制添加新记录,即使 paramId 已存在) + * 如果列表中已存在相同 modelPart 的记录,则添加新参数到该记录的列表尾部;否则添加新记录。 + * @param modelPart 变化的部件 + * @param paramId 参数id + * @param value 最终值 + */ + public void broadcast(ModelPart modelPart, String paramId, Object value) { + if (getSelectParameter() == null){ + return; + } + boolean isKeyframe = getSelectedKeyframe(false) != null; + Float currentKeyframe = getSelectedKeyframe(false); + + // 查找是否已存在该ModelPart的记录 + for (int i = 0; i < oldValues.size(); i++) { + Parameter existingParameter = oldValues.get(i); + if (existingParameter.modelPart().equals(modelPart)) { + // 更新现有记录(复制所有列表以确保记录的不可变性) + List newAnimationParameters = new ArrayList<>(existingParameter.animationParameter()); // NEW + List newParamIds = new ArrayList<>(existingParameter.paramId()); + List newValues = new ArrayList<>(existingParameter.value()); + List newKeyframes = new ArrayList<>(existingParameter.keyframe()); + List newIsKeyframes = new ArrayList<>(existingParameter.isKeyframe()); + + newAnimationParameters.add(getSelectParameter()); + newParamIds.add(paramId); + newValues.add(value); + newKeyframes.add(currentKeyframe); + newIsKeyframes.add(isKeyframe); + + Parameter updatedParameter = new Parameter(modelPart, newAnimationParameters, newParamIds, newValues, newKeyframes, newIsKeyframes); // NEW + oldValues.set(i, updatedParameter); + return; + } + } + + // 如果没有找到现有记录,创建新记录 + Parameter parameter = new Parameter( + modelPart, + java.util.Collections.singletonList(getSelectParameter()), // NEW + java.util.Collections.singletonList(paramId), + java.util.Collections.singletonList(value), + java.util.Collections.singletonList(currentKeyframe), + java.util.Collections.singletonList(isKeyframe) + ); + oldValues.add(parameter); + } + + /** + * 移除特定参数 + * @param modelPart 部件 + * @param paramId 参数id + */ + public void removeParameter(ModelPart modelPart, String paramId) { + for (int i = 0; i < oldValues.size(); i++) { + Parameter existingParameter = oldValues.get(i); + if (existingParameter.modelPart().equals(modelPart)) { + List newAnimationParameters = new ArrayList<>(existingParameter.animationParameter()); + List newParamIds = new ArrayList<>(existingParameter.paramId()); + List newValues = new ArrayList<>(existingParameter.value()); + List newKeyframes = new ArrayList<>(existingParameter.keyframe()); + List newIsKeyframes = new ArrayList<>(existingParameter.isKeyframe()); + + int paramIndex = newParamIds.indexOf(paramId); + if (paramIndex != -1) { + newAnimationParameters.remove(paramIndex); // NEW + newParamIds.remove(paramIndex); + newValues.remove(paramIndex); + newKeyframes.remove(paramIndex); + newIsKeyframes.remove(paramIndex); + + if (newParamIds.isEmpty()) { + oldValues.remove(i); + } else { + // 更新记录 + Parameter updatedParameter = new Parameter( + existingParameter.modelPart(), + newAnimationParameters, + newParamIds, + newValues, + newKeyframes, + newIsKeyframes + ); + oldValues.set(i, updatedParameter); + } + } + return; + } + } + } + + /** + * 获取参数值 (返回 ModelPart 的所有参数的防御性副本) + * @param modelPart 部件 + * @param paramId 参数id (该参数在此方法中将被忽略,因为返回的是所有参数) + * @return 该部件所有参数的 Parameter 记录的副本 + */ + public Parameter getValue(ModelPart modelPart, String paramId) { + for (Parameter parameter : oldValues) { + if (parameter.modelPart().equals(modelPart)) { + List indices = new ArrayList<>(); + for (int i = 0; i < parameter.paramId().size(); i++) { + if (parameter.paramId().get(i).equals(paramId)) indices.add(i); + } + if (indices.isEmpty()) return null; + List anims = new ArrayList<>(); + List ids = new ArrayList<>(); + List values = new ArrayList<>(); + List keyframes = new ArrayList<>(); + List isKeyframes = new ArrayList<>(); + for (int idx : indices) { + anims.add(parameter.animationParameter().get(idx)); + ids.add(parameter.paramId().get(idx)); + values.add(parameter.value().get(idx)); + keyframes.add(parameter.keyframe().get(idx)); + isKeyframes.add(parameter.isKeyframe().get(idx)); + } + return new Parameter(parameter.modelPart(), anims, ids, values, keyframes, isKeyframes); + } + } + return null; + } + + + public record Parameter( + ModelPart modelPart, + List animationParameter, + List paramId, + List value, + List keyframe, + List isKeyframe + ) { + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + String partName = (modelPart != null) ? modelPart.getName() : "[NULL ModelPart]"; + sb.append("Parameter[Part=").append(partName).append(", "); + sb.append("Details=["); + int size = paramId.size(); + for (int i = 0; i < size; i++) { + String id = paramId.get(i); + Object val = (value != null && value.size() > i) ? value.get(i) : null; + Float kf = (keyframe != null && keyframe.size() > i) ? keyframe.get(i) : null; + Boolean isKf = (isKeyframe != null && isKeyframe.size() > i) ? isKeyframe.get(i) : false; + if (i > 0) { + sb.append("; "); + } + sb.append(String.format("{ID=%s, V=%s, KF=%s, IsKF=%b}", + id, + String.valueOf(val), + kf != null ? String.valueOf(kf) : "null", + isKf)); + } + sb.append("]]"); + return sb.toString(); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("ParametersManagement State:\n"); + + if (oldValues.isEmpty()) { + sb.append(" No recorded parameters (oldValues is empty).\n"); + } else { + for (int i = 0; i < oldValues.size(); i++) { + Parameter p = oldValues.get(i); + sb.append(String.format(" --- Record %d ---\n", i)); + String partName; + if (p.modelPart() != null) { + partName = p.modelPart().getName(); + } else { + partName = "[NULL]"; + } + sb.append(String.format(" ModelPart: Part: %s\n", partName)); + int numParams = p.paramId().size(); + for (int j = 0; j < numParams; j++) { + String id = p.paramId().get(j); + Object val = (p.value() != null && p.value().size() > j) ? p.value().get(j) : "[MISSING_VALUE]"; + Float kf = (p.keyframe() != null && p.keyframe().size() > j) ? p.keyframe().get(j) : null; + Boolean isKf = (p.isKeyframe() != null && p.isKeyframe().size() > j) ? p.isKeyframe().get(j) : false; + sb.append(String.format(" - Param ID: %s, Value: %s, Keyframe: %s, IsKeyframe: %b\n", + id, + val != null ? String.valueOf(val) : "[NULL_VALUE]", + kf != null ? String.valueOf(kf) : "[NULL_KEYFRAME]", + isKf)); + } + } + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/SelectionTool.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/SelectionTool.java index db513a2..12a8c70 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/SelectionTool.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/SelectionTool.java @@ -1,6 +1,7 @@ package com.chuangzhou.vivid2D.render.awt.tools; import com.chuangzhou.vivid2D.render.awt.ModelRenderPanel; +import com.chuangzhou.vivid2D.render.awt.manager.ParametersManagement; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.chuangzhou.vivid2D.render.model.util.Mesh2D; @@ -318,15 +319,14 @@ public class SelectionTool extends Tool { float deltaX = modelX - dragStartX; float deltaY = modelY - dragStartY; - - // 移动所有选中的部件 List selectedParts = getSelectedParts(); for (ModelPart part : selectedParts) { - Vector2f pos = part.getPosition(); - part.setPosition(pos.x + deltaX, pos.y + deltaY); + Vector2f startPos = dragStartPositions.getOrDefault(part, new Vector2f(part.getPosition())); + float newX = startPos.x + deltaX; + float newY = startPos.y + deltaY; + part.setPosition(newX, newY); + renderPanel.getParametersManagement().broadcast(part, "position", List.of(newX, newY)); } - - // 更新拖拽起始位置 dragStartX = modelX; dragStartY = modelY; } @@ -336,29 +336,30 @@ public class SelectionTool extends Tool { */ private void handleRotateDrag(float modelX, float modelY) { if (lastSelectedMesh == null) return; - - // 计算当前角度 float currentAngle = (float) Math.atan2( modelY - renderPanel.getCameraManagement().getRotationCenter().y, modelX - renderPanel.getCameraManagement().getRotationCenter().x ); - - // 计算旋转增量 float deltaAngle = currentAngle - rotationStartAngle; - - // 如果按住Shift键,以15度为步长进行约束旋转 if (renderPanel.getKeyboardManager().getIsShiftPressed() || shiftDuringDrag) { float constraintStep = (float) (Math.PI / 12); // 15度 deltaAngle = Math.round(deltaAngle / constraintStep) * constraintStep; } - - // 应用旋转到所有选中的部件 List selectedParts = getSelectedParts(); for (ModelPart part : selectedParts) { - part.rotate(deltaAngle); + float startAngle = dragStartRotations.getOrDefault(part, part.getRotation()); + float targetAngle = startAngle + deltaAngle; + try { + part.getClass().getMethod("setRotation", float.class).invoke(part, targetAngle); + } catch (NoSuchMethodException nsme) { + float cur = part.getRotation(); + float delta = targetAngle - cur; + part.rotate(delta); + } catch (Exception ignored) { + part.rotate(deltaAngle); + } + renderPanel.getParametersManagement().broadcast(part, "rotate", part.getRotation()); } - - // 更新旋转起始角度 rotationStartAngle = currentAngle; } @@ -378,7 +379,7 @@ public class SelectionTool extends Tool { Vector2f currentPivot = selectedPart.getPivot(); float newPivotX = currentPivot.x + deltaX; float newPivotY = currentPivot.y + deltaY; - + renderPanel.getParametersManagement().broadcast(selectedPart, "pivot", List.of(newPivotX, newPivotY)); if (selectedPart.setPivot(newPivotX, newPivotY)) { dragStartX = modelX; dragStartY = modelY; @@ -437,19 +438,18 @@ public class SelectionTool extends Tool { } List selectedParts = getSelectedParts(); - Vector2f center = getMultiSelectionCenter(); // 整个多选的中心点 - + // 使用 dragStartScales 中记录的初始缩放来避免累积误差 for (ModelPart part : selectedParts) { - Vector2f currentScale = part.getScale(); - float newScaleX = currentScale.x * relScaleX; - float newScaleY = currentScale.y * relScaleY; + Vector2f startScale = dragStartScales.getOrDefault(part, new Vector2f(part.getScale())); + float newScaleX = startScale.x * relScaleX; + float newScaleY = startScale.y * relScaleY; - // 更新部件自身缩放 + // 更新部件自身缩放(先应用再广播绝对值) part.setScale(newScaleX, newScaleY); + renderPanel.getParametersManagement().broadcast(part, "scale", List.of(newScaleX, newScaleY)); } - - // 更新拖拽起始点和尺寸 + // 更新拖拽起始点和尺寸(保持与开始状态的相对关系) dragStartX = modelX; dragStartY = modelY; resizeStartWidth *= relScaleX; diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/VertexDeformationTool.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/VertexDeformationTool.java index 061d35a..d2ce4e4 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/VertexDeformationTool.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/tools/VertexDeformationTool.java @@ -14,6 +14,7 @@ import java.awt.*; import java.awt.event.MouseEvent; import java.awt.image.BufferedImage; import java.util.List; +import java.util.Map; /** * 顶点变形工具 @@ -32,6 +33,7 @@ public class VertexDeformationTool extends Tool { private float savedCameraRotation = Float.NaN; private Vector2f savedCameraScale = new Vector2f(1,1); private boolean cameraStateSaved = false; + public VertexDeformationTool(ModelRenderPanel renderPanel) { super(renderPanel, "顶点变形工具", "通过二级顶点对网格进行精细变形操作"); } @@ -52,26 +54,39 @@ public class VertexDeformationTool extends Tool { // 记录并重置相机(如果可用)到默认状态:旋转 = 0,缩放 = 1 try { - if (renderPanel.getCameraManagement() != null) { + if (renderPanel.getCameraManagement() != null && targetMesh != null && targetMesh.getModelPart() != null) { // 备份 savedCameraRotation = targetMesh.getModelPart().getRotation(); - savedCameraScale = targetMesh.getModelPart().getScale(); + savedCameraScale = new Vector2f(targetMesh.getModelPart().getScale().x, targetMesh.getModelPart().getScale().y); cameraStateSaved = true; - // 设置为默认 - targetMesh.getModelPart().setRotation(0f); - targetMesh.getModelPart().setScale(1f); + // 设置为默认(在 GL 线程中执行变更) + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + targetMesh.getModelPart().setRotation(0f); + targetMesh.getModelPart().setScale(1f); + targetMesh.getModelPart().updateMeshVertices(); + } catch (Throwable t) { + logger.debug("设置相机/部件默认状态时失败: {}", t.getMessage()); + } + }); } } catch (Throwable t) { - // 若没有这些方法或发生异常则记录但不阻塞工具激活 logger.debug("无法备份/设置相机状态: {}", t.getMessage()); } if (targetMesh != null) { // 显示二级顶点 associatedRanderTools.setAlgorithmEnabled("showSecondaryVertices", true); - targetMesh.setShowSecondaryVertices(true); - targetMesh.setRenderVertices(true); + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + targetMesh.setShowSecondaryVertices(true); + targetMesh.setRenderVertices(true); + targetMesh.updateBounds(); + } catch (Throwable t) { + logger.debug("激活顶点显示失败: {}", t.getMessage()); + } + }); logger.info("激活顶点变形工具: {}", targetMesh.getName()); } else { logger.warn("没有找到可用的网格用于顶点变形"); @@ -86,9 +101,16 @@ public class VertexDeformationTool extends Tool { // 恢复相机之前的旋转/缩放状态(如果已保存) try { - if (cameraStateSaved && renderPanel.getCameraManagement() != null) { - targetMesh.getModelPart().setRotation(savedCameraRotation); - targetMesh.getModelPart().setScale(savedCameraScale); + if (cameraStateSaved && renderPanel.getCameraManagement() != null && targetMesh != null && targetMesh.getModelPart() != null) { + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + targetMesh.getModelPart().setRotation(savedCameraRotation); + targetMesh.getModelPart().setScale(savedCameraScale); + targetMesh.getModelPart().updateMeshVertices(); + } catch (Throwable t) { + logger.debug("恢复相机/部件状态失败: {}", t.getMessage()); + } + }); } } catch (Throwable t) { logger.debug("无法恢复相机状态: {}", t.getMessage()); @@ -100,9 +122,19 @@ public class VertexDeformationTool extends Tool { if (targetMesh != null) { associatedRanderTools.setAlgorithmEnabled("showSecondaryVertices", false); - targetMesh.setShowSecondaryVertices(false); - targetMesh.setRenderVertices(false); - targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + targetMesh.setShowSecondaryVertices(false); + targetMesh.setRenderVertices(false); + // 标记脏,触发必要的刷新 + if (targetMesh.getModelPart() != null) { + targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); + targetMesh.getModelPart().updateMeshVertices(); + } + } catch (Throwable t) { + logger.debug("停用时清理失败: {}", t.getMessage()); + } + }); } targetMesh = null; selectedVertex = null; @@ -116,32 +148,38 @@ public class VertexDeformationTool extends Tool { public void onMousePressed(MouseEvent e, float modelX, float modelY) { if (!isActive || targetMesh == null) return; - // 选择二级顶点 - SecondaryVertex clickedVertex = findSecondaryVertexAtPosition(modelX, modelY); - if (clickedVertex != null) { - targetMesh.setSelectedSecondaryVertex(clickedVertex); - selectedVertex = clickedVertex; + // 选择二级顶点(select 操作不需要 GL 线程来 read,但为一致性在GL线程处理选择标记) + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + SecondaryVertex clickedVertex = findSecondaryVertexAtPosition(modelX, modelY); + if (clickedVertex != null) { + targetMesh.setSelectedSecondaryVertex(clickedVertex); + selectedVertex = clickedVertex; - // 开始拖拽 - currentDragMode = ModelRenderPanel.DragMode.MOVE_SECONDARY_VERTEX; - dragStartX = modelX; - dragStartY = modelY; + // 开始拖拽 + currentDragMode = ModelRenderPanel.DragMode.MOVE_SECONDARY_VERTEX; + dragStartX = modelX; + dragStartY = modelY; - logger.debug("开始移动二级顶点: ID={}, 位置({}, {})", - clickedVertex.getId(), modelX, modelY); - } else { - // 点击空白处,取消选择 - targetMesh.setSelectedSecondaryVertex(null); - selectedVertex = null; - currentDragMode = ModelRenderPanel.DragMode.NONE; - } + logger.debug("开始移动二级顶点: ID={}, 位置({}, {})", + clickedVertex.getId(), modelX, modelY); + } else { + // 点击空白处,取消选择 + targetMesh.setSelectedSecondaryVertex(null); + selectedVertex = null; + currentDragMode = ModelRenderPanel.DragMode.NONE; + } + } catch (Throwable t) { + logger.error("onMousePressed (VertexDeformationTool) 处理失败", t); + } + }); } @Override public void onMouseReleased(MouseEvent e, float modelX, float modelY) { if (!isActive) return; - // 记录操作历史 + // 记录操作历史(可在这里添加撤销记录) if (currentDragMode == ModelRenderPanel.DragMode.MOVE_SECONDARY_VERTEX && selectedVertex != null) { logger.debug("完成移动二级顶点: ID={}", selectedVertex.getId()); } @@ -154,19 +192,43 @@ public class VertexDeformationTool extends Tool { if (!isActive || selectedVertex == null) return; if (currentDragMode == ModelRenderPanel.DragMode.MOVE_SECONDARY_VERTEX) { + // 在 GL 线程中修改顶点与部件状态,保持线程安全与渲染同步 + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + // 移动顶点到新位置 + selectedVertex.setPosition(modelX, modelY); - // 移动顶点到新位置 - selectedVertex.setPosition(modelX, modelY); + // 广播:secondaryVertex -> { id, pos:[x,y] } + try { + if (targetMesh != null && targetMesh.getModelPart() != null) { + Map payload = Map.of( + "id", selectedVertex.getId(), + "pos", List.of(modelX, modelY) + ); + renderPanel.getParametersManagement().broadcast(targetMesh.getModelPart(), "secondaryVertex", payload); + //logger.info("广播 secondaryVertex: {}", payload); + } + } catch (Throwable bx) { + logger.debug("广播 secondaryVertex 失败: {}", bx.getMessage()); + } - // 更新拖拽起始位置 - dragStartX = modelX; - dragStartY = modelY; + // 更新拖拽起始位置 + dragStartX = modelX; + dragStartY = modelY; - // 标记网格为脏状态,需要重新计算边界等 - targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); - - // 强制重绘 - renderPanel.repaint(); + // 标记网格为脏状态,需要重新计算边界等 + if (targetMesh != null && targetMesh.getModelPart() != null) { + targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); + targetMesh.updateBounds(); + targetMesh.getModelPart().updateMeshVertices(); + } + } catch (Throwable t) { + logger.error("onMouseDragged (VertexDeformationTool) 处理失败", t); + } finally { + // 请求 UI 重绘(在 UI 线程) + renderPanel.repaint(); + } + }); } } @@ -174,13 +236,13 @@ public class VertexDeformationTool extends Tool { public void onMouseMoved(MouseEvent e, float modelX, float modelY) { if (!isActive || targetMesh == null) return; - // 更新悬停的二级顶点 + // 更新悬停的二级顶点(仅读取,不进行写入) —— 在主线程做轻量检测(容忍略微延迟) SecondaryVertex newHoveredVertex = findSecondaryVertexAtPosition(modelX, modelY); if (newHoveredVertex != hoveredVertex) { hoveredVertex = newHoveredVertex; - // 更新光标 + // 更新光标(在 UI 线程) if (hoveredVertex != null) { renderPanel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); } else { @@ -195,8 +257,7 @@ public class VertexDeformationTool extends Tool { // 如果点击了空白处且没有顶点被选中,可以创建新顶点 if (selectedVertex == null && findSecondaryVertexAtPosition(modelX, modelY) == null) { - // 这里可以选择是否允许通过单击创建顶点 - // createSecondaryVertexAt(modelX, modelY); + // 这里选择不在单击时自动创建顶点,保留为可选功能 } } @@ -204,15 +265,23 @@ public class VertexDeformationTool extends Tool { public void onMouseDoubleClicked(MouseEvent e, float modelX, float modelY) { if (!isActive || targetMesh == null) return; - // 检查是否双击了二级顶点 - SecondaryVertex clickedVertex = findSecondaryVertexAtPosition(modelX, modelY); - if (clickedVertex != null) { - // 双击二级顶点:删除该顶点 - deleteSecondaryVertex(clickedVertex); - } else { - // 双击空白处:创建新的二级顶点 - createSecondaryVertexAt(modelX, modelY); - } + // 双击需要修改模型,放到 GL 线程中 + renderPanel.getGlContextManager().executeInGLContext(() -> { + try { + SecondaryVertex clickedVertex = findSecondaryVertexAtPosition(modelX, modelY); + if (clickedVertex != null) { + // 双击二级顶点:删除该顶点 + deleteSecondaryVertex(clickedVertex); + } else { + // 双击空白处:创建新的二级顶点 + createSecondaryVertexAt(modelX, modelY); + } + } catch (Throwable t) { + logger.error("onMouseDoubleClicked (VertexDeformationTool) 处理失败", t); + } finally { + renderPanel.repaint(); + } + }); } @Override @@ -253,31 +322,51 @@ public class VertexDeformationTool extends Tool { private void createSecondaryVertexAt(float x, float y) { if (targetMesh == null) return; - // 确保边界框是最新的 - targetMesh.updateBounds(); - BoundingBox bounds = targetMesh.getBounds(); - if (bounds == null || !bounds.isValid()) { - logger.warn("无法创建二级顶点:边界框无效"); - return; - } + try { + // 确保边界框是最新的 + targetMesh.updateBounds(); + BoundingBox bounds = targetMesh.getBounds(); + if (bounds == null || !bounds.isValid()) { + logger.warn("无法创建二级顶点:边界框无效"); + return; + } - // 计算UV坐标(基于边界框) - float u = (x - bounds.getMinX()) / bounds.getWidth(); - float v = (y - bounds.getMinY()) / bounds.getHeight(); + // 计算UV坐标(基于边界框) + float u = (x - bounds.getMinX()) / bounds.getWidth(); + float v = (y - bounds.getMinY()) / bounds.getHeight(); - // 限制UV在0-1范围内 - u = Math.max(0.0f, Math.min(1.0f, u)); - v = Math.max(0.0f, Math.min(1.0f, v)); + // 限制UV在0-1范围内 + u = Math.max(0.0f, Math.min(1.0f, u)); + v = Math.max(0.0f, Math.min(1.0f, v)); - SecondaryVertex newVertex = targetMesh.addSecondaryVertex(x, y, u, v); - if (newVertex != null) { - logger.info("创建二级顶点: ID={}, 位置({}, {}), UV({}, {})", - newVertex.getId(), x, y, u, v); + SecondaryVertex newVertex = targetMesh.addSecondaryVertex(x, y, u, v); + if (newVertex != null) { + logger.info("创建二级顶点: ID={}, 位置({}, {}), UV({}, {})", + newVertex.getId(), x, y, u, v); - targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); - renderPanel.repaint(); - } else { - logger.warn("创建二级顶点失败"); + // 广播创建(GL 线程内) + try { + if (targetMesh.getModelPart() != null) { + Map payload = Map.of( + "id", newVertex.getId(), + "pos", List.of(x, y) + ); + renderPanel.getParametersManagement().broadcast(targetMesh.getModelPart(), "secondaryVertex", payload); + } + } catch (Throwable bx) { + logger.debug("广播 secondaryVertex(创建) 失败: {}", bx.getMessage()); + } + + if (targetMesh.getModelPart() != null) { + targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); + targetMesh.getModelPart().updateMeshVertices(); + } + renderPanel.repaint(); + } else { + logger.warn("创建二级顶点失败"); + } + } catch (Throwable t) { + logger.error("createSecondaryVertexAt 失败", t); } } @@ -287,21 +376,41 @@ public class VertexDeformationTool extends Tool { private void deleteSecondaryVertex(SecondaryVertex vertex) { if (targetMesh == null || vertex == null) return; - boolean removed = targetMesh.removeSecondaryVertex(vertex); - if (removed) { - if (selectedVertex == vertex) { - selectedVertex = null; - } - if (hoveredVertex == vertex) { - hoveredVertex = null; - } - logger.info("删除二级顶点: ID={}", vertex.getId()); + try { + boolean removed = targetMesh.removeSecondaryVertex(vertex); + if (removed) { + if (selectedVertex == vertex) { + selectedVertex = null; + } + if (hoveredVertex == vertex) { + hoveredVertex = null; + } + logger.info("删除二级顶点: ID={}", vertex.getId()); - // 标记网格为脏状态 - targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); - renderPanel.repaint(); - } else { - logger.warn("删除二级顶点失败: ID={}", vertex.getId()); + // 广播删除(将 pos 设为 null 表示删除,可由 FrameInterpolator 识别) + try { + if (targetMesh.getModelPart() != null) { + Map payload = Map.of( + "id", vertex.getId(), + "pos", null + ); + renderPanel.getParametersManagement().broadcast(targetMesh.getModelPart(), "secondaryVertex", payload); + } + } catch (Throwable bx) { + logger.debug("广播 secondaryVertex(删除) 失败: {}", bx.getMessage()); + } + + // 标记网格为脏状态 + if (targetMesh.getModelPart() != null) { + targetMesh.getModelPart().setPosition(targetMesh.getModelPart().getPosition()); + targetMesh.getModelPart().updateMeshVertices(); + } + renderPanel.repaint(); + } else { + logger.warn("删除二级顶点失败: ID={}", vertex.getId()); + } + } catch (Throwable t) { + logger.error("deleteSecondaryVertex 失败", t); } } @@ -376,4 +485,4 @@ public class VertexDeformationTool extends Tool { public boolean isDragging() { return currentDragMode == ModelRenderPanel.DragMode.MOVE_SECONDARY_VERTEX; } -} \ No newline at end of file +} diff --git a/src/main/java/com/chuangzhou/vivid2D/render/awt/util/FrameInterpolator.java b/src/main/java/com/chuangzhou/vivid2D/render/awt/util/FrameInterpolator.java new file mode 100644 index 0000000..8bce20f --- /dev/null +++ b/src/main/java/com/chuangzhou/vivid2D/render/awt/util/FrameInterpolator.java @@ -0,0 +1,630 @@ +package com.chuangzhou.vivid2D.render.awt.util; + +import com.chuangzhou.vivid2D.render.awt.manager.*; +import com.chuangzhou.vivid2D.render.model.*; +import org.slf4j.Logger; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FrameInterpolator { + private FrameInterpolator() {} + + // ---- 辅助转换方法(统一处理 Number / String / List 等) ---- + private static float toFloat(Object o) { + if (o == null) return 0f; + if (o instanceof Number) return ((Number) o).floatValue(); + if (o instanceof String) { + try { + return Float.parseFloat((String) o); + } catch (NumberFormatException ignored) { } + } + return 0f; + } + + private static float[] readVec2(Object o) { + float[] out = new float[]{0f, 0f}; + if (o == null) return out; + if (o instanceof List) { + List l = (List) o; + if (l.size() > 0) out[0] = toFloat(l.get(0)); + if (l.size() > 1) out[1] = toFloat(l.get(1)); + return out; + } + // 支持数组情况(float[] / Double[] / Number[]) + if (o.getClass().isArray()) { + Object[] arr = (Object[]) o; + if (arr.length > 0) out[0] = toFloat(arr[0]); + if (arr.length > 1) out[1] = toFloat(arr[1]); + return out; + } + // 单个数字 -> 两分量相同(兼容性) + if (o instanceof Number || o instanceof String) { + float v = toFloat(o); + out[0] = out[1] = v; + } + return out; + } + + // 兼容 AnimationParameter.getValue() 的安全读取(保留原反射回退) + private static float getAnimValueSafely(Object animParam) { + if (animParam == null) return 0f; + try { + if (animParam instanceof AnimationParameter) { + Object v = ((AnimationParameter) animParam).getValue(); + return toFloat(v); + } else { + try { + Object v = animParam.getClass().getMethod("getValue").invoke(animParam); + return toFloat(v); + } catch (NoSuchMethodException | IllegalAccessException | java.lang.reflect.InvocationTargetException ignored) {} + } + } catch (Throwable ignored) {} + return 0f; + } + + private static float normalizeAngle(float a) { + while (a <= -Math.PI) a += 2 * Math.PI; + while (a > Math.PI) a -= 2 * Math.PI; + return a; + } + + private static float normalizeAnimAngleUnits(float a) { + float abs = Math.abs(a); + if (abs > Math.PI * 2f) { + // 很可能是度而不是弧度 + return (float) Math.toRadians(a); + } + return a; + } + + // ---- 找 paramId 对应索引集合 ---- + private static List findIndicesForParam(ParametersManagement.Parameter fullParam, String paramId) { + List indices = new ArrayList<>(); + if (fullParam == null || fullParam.paramId() == null) return indices; + List pids = fullParam.paramId(); + for (int i = 0; i < pids.size(); i++) { + if (paramId.equals(pids.get(i))) indices.add(i); + } + return indices; + } + + // ---- 在指定索引集合中查找围绕 current 的前后关键帧(返回全局索引) ---- + private static int[] findSurroundingKeyframesForIndices(List animParams, List isKeyframeList, List indices, float current) { + int prevIndex = -1; + int nextIndex = -1; + float prevVal = Float.NEGATIVE_INFINITY; + float nextVal = Float.POSITIVE_INFINITY; + if (animParams == null || indices == null) return new int[]{-1, -1}; + for (int idx : indices) { + if (idx < 0 || idx >= animParams.size()) continue; + // 注意:这里不再强制要求 isKeyframe 为 true,因实时广播可能没有标记为 keyframe + float val = getAnimValueSafely(animParams.get(idx)); + if (val <= current) { + if (prevIndex == -1 || val >= prevVal) { + prevIndex = idx; + prevVal = val; + } + } + if (val >= current) { + if (nextIndex == -1 || val <= nextVal) { + nextIndex = idx; + nextVal = val; + } + } + } + return new int[] { prevIndex, nextIndex }; + } + + private static float computeT(float prevVal, float nextVal, float current) { + if (Float.compare(nextVal, prevVal) == 0) return 0f; + float t = (current - prevVal) / (nextVal - prevVal); + if (t < 0f) t = 0f; + if (t > 1f) t = 1f; + return t; + } + + // ---- 计算 position/scale/pivot 的目标值(在 fullParam 的特定 paramId 索引集合中计算) ---- + private static boolean computeVec2Target(ParametersManagement.Parameter fullParam, String paramId, float current, float[] out) { + if (fullParam == null || out == null) return false; + List idxs = findIndicesForParam(fullParam, paramId); + if (idxs.isEmpty()) return false; + List animParams = fullParam.animationParameter(); + //List isKey = fullParam.isKeyframe(); // 不强制使用 isKeyframe + int[] idx = findSurroundingKeyframesForIndices(animParams, fullParam.isKeyframe(), idxs, current); + int prevIndex = idx[0], nextIndex = idx[1]; + + List values = fullParam.value(); + if (values == null) return false; + try { + if (prevIndex != -1 && nextIndex != -1) { + if (prevIndex == nextIndex) { + float[] v = readVec2(values.get(prevIndex)); + out[0] = v[0]; out[1] = v[1]; + return true; + } else { + float[] prev = readVec2(values.get(prevIndex)); + float[] next = readVec2(values.get(nextIndex)); + float prevVal = getAnimValueSafely(animParams.get(prevIndex)); + float nextVal = getAnimValueSafely(animParams.get(nextIndex)); + float t = computeT(prevVal, nextVal, current); + out[0] = prev[0] + t * (next[0] - prev[0]); + out[1] = prev[1] + t * (next[1] - prev[1]); + return true; + } + } else if (prevIndex != -1) { + float[] v = readVec2(values.get(prevIndex)); + out[0] = v[0]; out[1] = v[1]; + return true; + } else if (nextIndex != -1) { + float[] v = readVec2(values.get(nextIndex)); + out[0] = v[0]; out[1] = v[1]; + return true; + } else { + // 精确匹配(兜底) + for (int i : idxs) { + if (i < 0 || i >= animParams.size()) continue; + // 允许非 keyframe 的值作为实时覆盖 + float val = getAnimValueSafely(animParams.get(i)); + if (Float.compare(val, current) == 0) { + float[] v = readVec2(values.get(i)); + out[0] = v[0]; out[1] = v[1]; + return true; + } + } + } + } catch (Throwable ignored) {} + return false; + } + + private static boolean computeRotationTargetGeneric(ParametersManagement.Parameter fullParam, String paramId, float current, float[] outSingle) { + if (fullParam == null || outSingle == null) return false; + List idxs = findIndicesForParam(fullParam, paramId); + if (idxs.isEmpty()) return false; + List animParams = fullParam.animationParameter(); + List values = fullParam.value(); + int[] idx = findSurroundingKeyframesForIndices(animParams, fullParam.isKeyframe(), idxs, current); + int prevIndex = idx[0], nextIndex = idx[1]; + + try { + float target; + if (prevIndex != -1 && nextIndex != -1) { + if (prevIndex == nextIndex) { + target = toFloat(values.get(prevIndex)); + } else { + float p = toFloat(values.get(prevIndex)); + float q = toFloat(values.get(nextIndex)); + float prevVal = getAnimValueSafely(animParams.get(prevIndex)); + float nextVal = getAnimValueSafely(animParams.get(nextIndex)); + float t = computeT(prevVal, nextVal, current); + float diff = normalizeAngle(q - p); + target = p + diff * t; + } + } else if (prevIndex != -1) { + target = toFloat(values.get(prevIndex)); + } else if (nextIndex != -1) { + target = toFloat(values.get(nextIndex)); + } else { + float found = Float.NaN; + for (int i : idxs) { + if (i < 0 || i >= animParams.size()) continue; + float val = getAnimValueSafely(animParams.get(i)); + if (Float.compare(val, current) == 0) { + found = toFloat(values.get(i)); + break; + } + } + if (Float.isNaN(found)) return false; + target = found; + } + + outSingle[0] = normalizeAnimAngleUnits(target); + return true; + } catch (Throwable ignored) {} + return false; + } + + // ---- Secondary vertex 插值(为每个 vertex id 计算目标) ---- + // 返回列表:每个 SecondaryVertexTarget 表示 id -> 插值后的位置 或 标记为 deleted + private static List computeAllSecondaryVertexTargets(ParametersManagement.Parameter fullParam, String paramId, float current) { + List results = new ArrayList<>(); + if (fullParam == null) return results; + + List idxs = findIndicesForParam(fullParam, paramId); + if (idxs.isEmpty()) return results; + + List animParams = fullParam.animationParameter(); + List values = fullParam.value(); + if (animParams == null || values == null) return results; + + // 按 vertex id 分组(包含 keyframe 与 非 keyframe,允许实时覆盖) + Map> idToIndices = new HashMap<>(); + for (int i : idxs) { + if (i < 0 || i >= values.size() || i >= animParams.size()) continue; + ParsedVertex pv = parseVertexValue(values.get(i)); + if (pv == null) continue; + idToIndices.computeIfAbsent(pv.id, k -> new ArrayList<>()).add(i); + } + + // 对每个 id 单独计算 prev/next 并插值(包括处理删除标记) + for (Map.Entry> e : idToIndices.entrySet()) { + int vid = e.getKey(); + List list = e.getValue(); + if (list.isEmpty()) continue; + + int prevIndex = -1, nextIndex = -1; + float prevVal = Float.NEGATIVE_INFINITY, nextVal = Float.POSITIVE_INFINITY; + for (int idx : list) { + float val = getAnimValueSafely(animParams.get(idx)); + if (val <= current) { + if (prevIndex == -1 || val >= prevVal) { + prevIndex = idx; + prevVal = val; + } + } + if (val >= current) { + if (nextIndex == -1 || val <= nextVal) { + nextIndex = idx; + nextVal = val; + } + } + } + + try { + // 优先 prev/next 插值或取值;若存在 pos==null 的条目(删除),则将其转为 deleted 标记 + if (prevIndex != -1 && nextIndex != -1) { + if (prevIndex == nextIndex) { + ParsedVertex pv = parseVertexValue(values.get(prevIndex)); + if (pv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; + tgt.deleted = pv.deleted; + tgt.x = pv.x; + tgt.y = pv.y; + results.add(tgt); + } + } else { + ParsedVertex pv = parseVertexValue(values.get(prevIndex)); + ParsedVertex nv = parseVertexValue(values.get(nextIndex)); + if (pv == null || nv == null) { + // 若任意一端为 null,则退化为非插值处理(尝试取有意义的一端) + if (pv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; tgt.deleted = pv.deleted; tgt.x = pv.x; tgt.y = pv.y; + results.add(tgt); + } else if (nv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; tgt.deleted = nv.deleted; tgt.x = nv.x; tgt.y = nv.y; + results.add(tgt); + } + } else { + // 如果任意一侧为 deleted(pos==null),则不做插值(选择 prev 的删除/存在状态) + if (pv.deleted || nv.deleted) { + // 选择较靠近 current 的那端(这里优先 prev) + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; + tgt.deleted = pv.deleted; + tgt.x = pv.x; + tgt.y = pv.y; + results.add(tgt); + } else { + float t = computeT(prevVal, nextVal, current); + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; + tgt.deleted = false; + tgt.x = pv.x + t * (nv.x - pv.x); + tgt.y = pv.y + t * (nv.y - pv.y); + results.add(tgt); + } + } + } + } else if (prevIndex != -1) { + ParsedVertex pv = parseVertexValue(values.get(prevIndex)); + if (pv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; tgt.deleted = pv.deleted; tgt.x = pv.x; tgt.y = pv.y; + results.add(tgt); + } + } else if (nextIndex != -1) { + ParsedVertex nv = parseVertexValue(values.get(nextIndex)); + if (nv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; tgt.deleted = nv.deleted; tgt.x = nv.x; tgt.y = nv.y; + results.add(tgt); + } + } else { + // 兜底:查找与 current 相等的条目 + for (int idx : list) { + float val = getAnimValueSafely(animParams.get(idx)); + if (Float.compare(val, current) == 0) { + ParsedVertex pv = parseVertexValue(values.get(idx)); + if (pv != null) { + SecondaryVertexTarget tgt = new SecondaryVertexTarget(); + tgt.id = vid; tgt.deleted = pv.deleted; tgt.x = pv.x; tgt.y = pv.y; + results.add(tgt); + break; + } + } + } + } + } catch (Throwable ignored) {} + } + + return results; + } + + // 辅助结构 + private static class ParsedVertex { + int id; + float x; + float y; + boolean deleted; // pos == null 表示删除 + ParsedVertex(int id, float x, float y, boolean deleted) { this.id = id; this.x = x; this.y = y; this.deleted = deleted; } + } + + private static ParsedVertex parseVertexValue(Object v) { + if (v == null) return null; + try { + if (v instanceof Map) { + Map m = (Map) v; + Object idObj = m.get("id"); + // pos 可能为 null(表示删除) + Object posObj = m.get("pos"); + if (idObj == null) return null; + int id = (idObj instanceof Number) ? ((Number) idObj).intValue() : Integer.parseInt(String.valueOf(idObj)); + if (posObj == null) { + return new ParsedVertex(id, 0f, 0f, true); + } + float[] p = readVec2(posObj); + return new ParsedVertex(id, p[0], p[1], false); + } else if (v instanceof List) { + List l = (List) v; + if (l.size() >= 3) { + Object idObj = l.get(0); + if (!(idObj instanceof Number)) return null; + int id = ((Number) idObj).intValue(); + float x = toFloat(l.get(1)); + float y = toFloat(l.get(2)); + return new ParsedVertex(id, x, y, false); + } else if (l.size() == 2) { + // [ id, null ] 之类(不常见) + Object idObj = l.get(0); + if (!(idObj instanceof Number)) return null; + int id = ((Number) idObj).intValue(); + Object posObj = l.get(1); + if (posObj == null) return new ParsedVertex(id, 0f, 0f, true); + } + } else { + // 可能是自定义对象,尝试反射获取 id/pos(容错) + try { + Object idObj = v.getClass().getMethod("getId").invoke(v); + Object px = v.getClass().getMethod("getX").invoke(v); + Object py = v.getClass().getMethod("getY").invoke(v); + int id = idObj instanceof Number ? ((Number) idObj).intValue() : Integer.parseInt(String.valueOf(idObj)); + return new ParsedVertex(id, toFloat(px), toFloat(py), false); + } catch (Throwable ignored) {} + } + } catch (Throwable ignored) {} + return null; + } + + // 用于输出 secondary vertex 结果 + private static class SecondaryVertexTarget { + int id; + float x; + float y; + boolean deleted = false; + } + + /** + * 将 SelectionTool 的四类操作(pivot/scale/rotate/position/secondaryVertex)按当前关键帧插值并应用到 parts。 + * 该方法应在 GL 上下文线程中被调用(即 glContextManager.executeInGLContext 内)。 + * + * 对每个 part:先计算所有目标(如果存在),再一次性按 pivot->scale->rotation->position 的顺序应用, + * 并在最后只做一次 updateMeshVertices/updateBounds。 + */ + public static void applyFrameInterpolations(ParametersManagement pm, + List parts, + AnimationParameter currentAnimationParameter, + Logger logger) { + if (pm == null || parts == null || parts.isEmpty() || currentAnimationParameter == null) return; + float current = 0f; + try { + Object v = currentAnimationParameter.getValue(); + current = toFloat(v); + } catch (Exception ex) { + logger.debug("读取当前动画参数值失败,使用0作为默认值", ex); + } + + for (ModelPart part : parts) { + try { + // Full parameter record for this ModelPart (contains lists for all paramIds) + ParametersManagement.Parameter fullParam = pm.getModelPartParameters(part); + if (fullParam == null) { + // 没有记录则继续 + continue; + } + + // 目标容器(null 表示未设置) + float[] targetPivot = null; + float[] targetScale = null; + Float targetRotation = null; + float[] targetPosition = null; + List svTargets = null; + + // pivot + float[] tmp2 = new float[2]; + if (computeVec2Target(fullParam, "pivot", current, tmp2)) { + targetPivot = new float[]{tmp2[0], tmp2[1]}; + } + + // scale + if (computeVec2Target(fullParam, "scale", current, tmp2)) { + targetScale = new float[]{tmp2[0], tmp2[1]}; + } + + // rotate + float[] tmp1 = new float[1]; + if (computeRotationTargetGeneric(fullParam, "rotate", current, tmp1)) { + targetRotation = tmp1[0]; + } + + // position + if (computeVec2Target(fullParam, "position", current, tmp2)) { + targetPosition = new float[]{tmp2[0], tmp2[1]}; + } + + // secondaryVertex: 为每个记录的 vertex id 计算目标位置(包含实时广播) + List computedSV = computeAllSecondaryVertexTargets(fullParam, "secondaryVertex", current); + if (computedSV != null && !computedSV.isEmpty()) { + svTargets = computedSV; + } + + // 如果没有任何要修改的,跳过 + if (targetPivot == null && targetScale == null && targetRotation == null && targetPosition == null && (svTargets == null || svTargets.isEmpty())) { + continue; + } + + // 记录当前状态(用于没有 setRotation 方法时计算 delta) + float currentRot = part.getRotation(); + + // 一次性应用:pivot -> scale -> rotation -> position(最小化中间更新) + try { + if (targetPivot != null) { + part.setPivot(targetPivot[0], targetPivot[1]); + } + + if (targetScale != null) { + part.setScale(targetScale[0], targetScale[1]); + } + + if (targetRotation != null) { + try { + Method setRotation = part.getClass().getMethod("setRotation", float.class); + setRotation.invoke(part, targetRotation); + } catch (NoSuchMethodException nsme) { + float delta = normalizeAngle(targetRotation - currentRot); + part.rotate(delta); + } catch (Exception ignored) {} + } + + if (targetPosition != null) { + part.setPosition(targetPosition[0], targetPosition[1]); + } + + // 处理 secondary vertex:对每个计算出的目标,找到对应 mesh 与 vertex id,设置顶点位置或删除 + if (svTargets != null && !svTargets.isEmpty()) { + try { + List meshes = part.getMeshes(); + if (meshes != null) { + for (SecondaryVertexTarget s : svTargets) { + boolean appliedGlobal = false; + for (com.chuangzhou.vivid2D.render.model.util.Mesh2D mesh : meshes) { + if (mesh == null) continue; + try { + boolean applied = false; + // 优先尝试 mesh.getSecondaryVertices() + try { + List svs = + (List) mesh.getClass().getMethod("getSecondaryVertices").invoke(mesh); + if (svs != null) { + com.chuangzhou.vivid2D.render.model.util.SecondaryVertex found = null; + for (com.chuangzhou.vivid2D.render.model.util.SecondaryVertex sv : svs) { + if (sv != null && sv.getId() == s.id) { + found = sv; + break; + } + } + if (found != null) { + if (s.deleted) { + // 尝试通过 mesh 提供的 remove 方法 + try { + mesh.getClass().getMethod("removeSecondaryVertex", com.chuangzhou.vivid2D.render.model.util.SecondaryVertex.class) + .invoke(mesh, found); + } catch (NoSuchMethodException nsme) { + // 没有 remove 方法则尝试 mesh.removeSecondaryVertex(found) via known signature + try { + mesh.getClass().getMethod("removeSecondaryVertexById", int.class).invoke(mesh, s.id); + } catch (Throwable ignore) { + // 最后作为保险:尝试直接从 list 删除(不推荐,但做容错) + try { + svs.remove(found); + } catch (Throwable ignore2) {} + } + } catch (Throwable ignore) {} + } else { + found.setPosition(s.x, s.y); + } + applied = true; + } + } + } catch (NoSuchMethodException nsme) { + // 忽略:没有该方法 + } catch (Throwable ignore2) { + // 忽略运行时异常 + } + + // 回退策略:尝试 getSecondaryVertexById(int) + if (!applied) { + try { + Object svObj = mesh.getClass().getMethod("getSecondaryVertexById", int.class).invoke(mesh, s.id); + if (svObj instanceof com.chuangzhou.vivid2D.render.model.util.SecondaryVertex) { + com.chuangzhou.vivid2D.render.model.util.SecondaryVertex found = (com.chuangzhou.vivid2D.render.model.util.SecondaryVertex) svObj; + if (s.deleted) { + try { + mesh.getClass().getMethod("removeSecondaryVertex", com.chuangzhou.vivid2D.render.model.util.SecondaryVertex.class) + .invoke(mesh, found); + } catch (Throwable ignore) {} + } else { + found.setPosition(s.x, s.y); + } + applied = true; + } + } catch (NoSuchMethodException ignoreMethod) { + // 忽略 + } catch (Throwable ignore3) {} + } + + if (applied) { + appliedGlobal = true; + break; + } + } catch (Throwable ignored) {} + } + // 未找到对应 id 的 mesh/vertex 则忽略(容错) + if (!appliedGlobal) { + // 可选: 记录日志方便调试 + logger.debug("未找到二级顶点 id={} 的对应 mesh/vertex", s.id); + } + } + } + } catch (Throwable ignored) {} + } + + // 最后统一刷新一次顶点与 bounds,避免频繁刷新导致与交互冲突 + try { + part.updateMeshVertices(); + if (part.getMeshes() != null) { + for (Object m : part.getMeshes()) { + try { + if (m instanceof com.chuangzhou.vivid2D.render.model.util.Mesh2D) { + ((com.chuangzhou.vivid2D.render.model.util.Mesh2D) m).updateBounds(); + } + } catch (Throwable ignored) {} + } + } + } catch (Throwable ignored) {} + + } catch (Throwable t) { + logger.debug("应用目标变换时失败(single part): {}", t.getMessage()); + } + + } catch (Exception ex) { + logger.error("FrameInterpolator 在应用插值时发生异常", ex); + } + } + } +} diff --git a/src/main/java/com/chuangzhou/vivid2D/render/model/AnimationParameter.java b/src/main/java/com/chuangzhou/vivid2D/render/model/AnimationParameter.java index 6d8529e..6e5dc22 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/AnimationParameter.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/AnimationParameter.java @@ -1,5 +1,10 @@ package com.chuangzhou.vivid2D.render.model; +import java.util.Collections; +import java.util.Objects; +import java.util.SortedSet; +import java.util.TreeSet; + public class AnimationParameter { private final String id; private float value; @@ -8,6 +13,8 @@ public class AnimationParameter { private final float maxValue; private boolean changed = false; + private final TreeSet keyframes = new TreeSet<>(); + public AnimationParameter(String id, float min, float max, float defaultValue) { this.id = id; this.minValue = min; @@ -24,6 +31,17 @@ public class AnimationParameter { } } + /** + * @return 一个新的 AnimationParameter 实例,包含相同的配置、值、状态和关键帧。 + */ + public AnimationParameter copy() { + AnimationParameter copy = new AnimationParameter(this.id, this.minValue, this.maxValue, this.defaultValue); + copy.value = this.value; + copy.changed = this.changed; + copy.keyframes.addAll(this.keyframes); + return copy; + } + public boolean hasChanged() { return changed; } @@ -53,21 +71,150 @@ public class AnimationParameter { } public void reset() { - this.value = defaultValue; - this.changed = false; + setValue(defaultValue); } /** * 获取归一化值 [0, 1] */ public float getNormalizedValue() { - return (value - minValue) / (maxValue - minValue); + float range = maxValue - minValue; + if (range == 0) return 0; + return (value - minValue) / range; } /** * 设置归一化值 */ public void setNormalizedValue(float normalized) { - this.value = minValue + normalized * (maxValue - minValue); + float newValue = minValue + normalized * (maxValue - minValue); + setValue(newValue); // 使用 setValue 来确保钳位和 'changed' 标记 + } + + + /** + * 添加一个关键帧。值会被自动钳位(clamp)到 min/max 范围内。 + * @param frameValue 参数值 + * @return 如果成功添加了新帧,返回 true;如果帧已存在,返回 false。 + */ + public boolean addKeyframe(float frameValue) { + float clampedValue = Math.max(minValue, Math.min(maxValue, frameValue)); + return keyframes.add(clampedValue); + } + + /** + * 移除一个关键帧。 + * @param frameValue 参数值 + * @return 如果成功移除了该帧,返回 true;如果帧不存在,返回 false。 + */ + public boolean removeKeyframe(float frameValue) { + return keyframes.remove(frameValue); + } + + /** + * 检查某个值是否是关键帧。 + * @param frameValue 参数值 + * @return 如果是,返回 true。 + */ + public boolean isKeyframe(float frameValue) { + // 使用 epsilon 进行浮点数比较可能更稳健,但 TreeSet 存储的是精确值 + // 为了简单起见,我们假设我们操作的是精确的 float + return keyframes.contains(frameValue); + } + + /** + * 获取所有关键帧的只读、排序视图。 + * @return 排序后的关键帧集合 + */ + public SortedSet getKeyframes() { + return Collections.unmodifiableSortedSet(keyframes); + } + + /** + * 清除所有关键帧。 + */ + public void clearKeyframes() { + keyframes.clear(); + } + + /** + * 查找在给定阈值(threshold)内最接近指定值的关键帧。 + * + * @param value 要查找的值 + * @param snapThreshold 绝对吸附阈值 (例如 0.05) + * @return 如果找到,返回最近的帧值;否则返回 null。 + */ + public Float getNearestKeyframe(float value, float snapThreshold) { + if (snapThreshold <= 0) return null; + + // 查找 value 附近的关键帧 + SortedSet head = keyframes.headSet(value); + SortedSet tail = keyframes.tailSet(value); + + Float prev = head.isEmpty() ? null : head.last(); + Float next = tail.isEmpty() ? null : tail.first(); + + float distToPrev = prev != null ? Math.abs(value - prev) : Float.MAX_VALUE; + float distToNext = next != null ? Math.abs(value - next) : Float.MAX_VALUE; + + if (distToPrev < snapThreshold && distToPrev <= distToNext) { + return prev; + } + if (distToNext < snapThreshold && distToNext < distToPrev) { + return next; + } + + return null; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + AnimationParameter that = (AnimationParameter) obj; + // 比较所有定义参数的 final 字段和关键帧集合 + return Float.compare(that.defaultValue, defaultValue) == 0 && + Float.compare(that.minValue, minValue) == 0 && + Float.compare(that.maxValue, maxValue) == 0 && + Objects.equals(id, that.id) && + Objects.equals(keyframes, that.keyframes); + } + + @Override + public String toString() { + String idStr = Objects.requireNonNullElse(id, "[null id]"); + String valStr = String.format("%.3f", value); + String minStr = String.format("%.3f", minValue); + String maxStr = String.format("%.3f", maxValue); + String defStr = String.format("%.3f", defaultValue); + + StringBuilder sb = new StringBuilder(); + + sb.append("AnimationParameter[ID=").append(idStr); + sb.append(", Value=").append(valStr); + sb.append(changed ? " (Changed)" : ""); + sb.append(", Range=[").append(minStr).append(", ").append(maxStr).append("]"); + sb.append(", Default=").append(defStr); + if (keyframes.isEmpty()) { + sb.append(", Keyframes=[]"); + } else { + sb.append(", Keyframes=["); + boolean first = true; + for (Float kf : keyframes) { + if (!first) { + sb.append(", "); + } + sb.append(String.format("%.3f", kf)); + first = false; + } + sb.append("]"); + } + + sb.append("]"); + return sb.toString(); } } \ No newline at end of file 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 4f5483c..500de18 100644 --- a/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java +++ b/src/main/java/com/chuangzhou/vivid2D/render/model/ModelPart.java @@ -43,6 +43,7 @@ public class ModelPart { // ==================== 变形系统 ==================== private final List deformers; private final List liquifyStrokes = new ArrayList<>(); + private final Map parameters; // ==================== 状态标记 ==================== private boolean transformDirty; @@ -51,7 +52,6 @@ public class ModelPart { private final List events = new ArrayList<>(); private boolean inMultiSelectionOperation = false; - private boolean startLiquefy = false; // ====== 液化模式枚举 ====== public enum LiquifyMode { @@ -65,6 +65,31 @@ public class ModelPart { TURBULENCE // 湍流(噪声扰动) } + public AnimationParameter createParameter(String id, float min, float max, float defaultValue) { + AnimationParameter param = new AnimationParameter(id, min, max, defaultValue); + parameters.put(id, param); + return param; + } + + public AnimationParameter getParameter(String id) { + return parameters.get(id); + } + + public Map getParameters() { + return parameters; + } + + public void addParameter(AnimationParameter param) { + parameters.put(param.getId(), param); + } + + public void setParameterValue(String paramId, float value) { + AnimationParameter param = parameters.get(paramId); + if (param != null) { + param.setValue(value); + markTransformDirty(); + } + } // ==================== 构造器 ==================== @@ -77,25 +102,18 @@ public class ModelPart { this.children = new ArrayList<>(); this.meshes = new ArrayList<>(); this.deformers = new ArrayList<>(); - - // 初始化变换属性 this.position = new Vector2f(); this.rotation = 0.0f; this.scale = new Vector2f(1.0f, 1.0f); this.localTransform = new Matrix3f(); this.worldTransform = new Matrix3f(); - - // 初始化渲染属性 this.visible = true; this.blendMode = BlendMode.NORMAL; this.opacity = 1.0f; - - // 标记需要更新 + parameters = new HashMap<>(); this.transformDirty = true; this.boundsDirty = true; - updateLocalTransform(); - // 初始时 worldTransform = localTransform(无父节点时) recomputeWorldTransformRecursive(); } @@ -117,7 +135,6 @@ public class ModelPart { * 设置液化状态 */ public void setStartLiquefy(boolean startLiquefy) { - this.startLiquefy = startLiquefy; // 同步到所有网格 for (Mesh2D mesh : meshes) { 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 ad7927b..26aba0c 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 @@ -43,7 +43,6 @@ public class Mesh2D { private ModelPart modelPart; private float[] renderVertices; - // ==================== 二级顶点支持 ==================== private final List secondaryVertices = new ArrayList<>(); private boolean showSecondaryVertices = false; diff --git a/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java b/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java index 422537d..0873b8c 100644 --- a/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java +++ b/src/main/java/com/chuangzhou/vivid2D/test/ModelLayerPanelTest.java @@ -2,7 +2,9 @@ package com.chuangzhou.vivid2D.test; import com.chuangzhou.vivid2D.render.awt.ModelLayerPanel; import com.chuangzhou.vivid2D.render.awt.ModelRenderPanel; +import com.chuangzhou.vivid2D.render.awt.ParametersPanel; import com.chuangzhou.vivid2D.render.awt.TransformPanel; +import com.chuangzhou.vivid2D.render.awt.manager.ParametersManagement; import com.chuangzhou.vivid2D.render.model.Model2D; import com.chuangzhou.vivid2D.render.model.ModelPart; import com.formdev.flatlaf.themes.FlatMacDarkLaf; @@ -52,6 +54,11 @@ public class ModelLayerPanelTest { rightTabbedPane.addTab("变换控制", transformScroll); rightTabbedPane.setPreferredSize(new Dimension(300, 600)); frame.add(rightTabbedPane, BorderLayout.EAST); + ParametersPanel parametersPanel = new ParametersPanel(renderPanel); + renderPanel.setParametersManagement(new ParametersManagement(parametersPanel)); + JScrollPane paramScroll = new JScrollPane(parametersPanel); + paramScroll.setPreferredSize(new Dimension(280, 600)); + rightTabbedPane.addTab("参数管理", paramScroll); JPanel bottom = getBottom(renderPanel, transformPanel); frame.add(bottom, BorderLayout.SOUTH); renderPanel.addModelClickListener((mesh, modelX, modelY, screenX, screenY) -> {