feat(window): 实现全局快捷键管理和模型修改状态跟踪- 新增 KeyBindingManager 类,负责注册和管理全局快捷键- 在 MainWindow 中集成快捷键管理器,并暴露 saveData 方法

- 实现模型修改状态跟踪机制,支持退出前保存提示
-重构图层面板的文件拖放逻辑,支持窗口级和列表级拖放处理
-修复图层名称重复问题,确保新建图层名称唯一性
- 优化图层删除逻辑,支持多选删除和参数清理
- 改进贴图绑定逻辑,确保正确设置网格纹理
- 更新 Mesh2D 中原始轴心点计算方法,使用原始边界而非当前边界
This commit is contained in:
tzdwindows 7
2025-11-08 11:54:16 +08:00
parent bec9ccf64f
commit b17bd500f2
7 changed files with 444 additions and 117 deletions

View File

@@ -5,11 +5,13 @@ import com.chuangzhou.vivid2D.render.awt.manager.ThumbnailManager;
import com.chuangzhou.vivid2D.render.awt.manager.data.LayerOperationManagerData;
import com.chuangzhou.vivid2D.render.awt.util.*;
import com.chuangzhou.vivid2D.render.awt.util.renderer.LayerCellRenderer;
// 确保 LayerReorderTransferHandler 被正确导入,它处理内部重排
import com.chuangzhou.vivid2D.render.awt.util.renderer.LayerReorderTransferHandler;
import com.chuangzhou.vivid2D.render.model.Model2D;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.util.Mesh2D;
import com.chuangzhou.vivid2D.render.model.util.Texture;
import com.chuangzhou.vivid2D.window.MainWindow;
import org.joml.Vector2f;
import javax.imageio.ImageIO;
@@ -18,6 +20,8 @@ import javax.swing.border.EmptyBorder;
import javax.swing.border.TitledBorder;
import javax.swing.plaf.basic.BasicSliderUI;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.RoundRectangle2D;
@@ -34,10 +38,13 @@ import java.util.List;
import java.util.Map;
// 引入 JnaFileChooser
import jnafilechooser.api.JnaFileChooser;
// 引入拖放相关的类
// 引入拖放和快捷键相关的类
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import javax.swing.TransferHandler;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.KeyStroke;
public class ModelLayerPanel extends JPanel {
@@ -99,9 +106,8 @@ public class ModelLayerPanel extends JPanel {
setupModernLookAndFeel();
this.thumbnailManager = new ThumbnailManager(renderPanel);
// --- 新增:设置外部文件拖放处理器 ---
this.setTransferHandler(new FileDropTransferHandler());
// ---------------------------------
// FIX: 移除在 this 上的 TransferHandler因为 JList 将会覆盖它
// this.setTransferHandler(new FileDropTransferHandler());
if (this.model != null) {
this.psdImporter = new PSDImporter(model, renderPanel, this);
@@ -125,6 +131,19 @@ public class ModelLayerPanel extends JPanel {
}
}
private void setupWindowFileDropHandler() {
SwingUtilities.invokeLater(() -> {
Window window = SwingUtilities.getWindowAncestor(this);
// 确保找到的 window 是 RootPaneContainer (如 JFrame, JDialog)
if (window instanceof MainWindow mainWindow) {
JComponent contentPane = (JComponent) mainWindow.getContentPane();
if (contentPane != null) {
contentPane.setTransferHandler(new FileDropOnlyTransferHandler());
}
}
});
}
public void loadMetadata() {
String modelDataPath = renderPanel.getGlContextManager().getModelPath() + ".data";
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(modelDataPath))) {
@@ -185,6 +204,10 @@ public class ModelLayerPanel extends JPanel {
gbc.fill = GridBagConstraints.BOTH;
add(centerScrollPane, gbc);
setupWindowFileDropHandler();
// 绑定快捷键
setupKeyBindings(layerList);
JPanel controlPanel = createControlPanel();
gbc.gridy = 2;
gbc.weighty = 0.0;
@@ -193,9 +216,37 @@ public class ModelLayerPanel extends JPanel {
add(controlPanel, gbc);
}
/**
* 设置快捷键绑定。
* @param list JList 组件
*/
private void setupKeyBindings(JList<ModelPart> list) {
// 关键更改:从 JList 获取 InputMap但使用 WHEN_IN_FOCUSED_WINDOW 模式
// 这样,只要包含这个 ModelLayerPanel 的窗口处于焦点状态,快捷键就会生效。
InputMap inputMap = list.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
// 获取组件的 ActionMap
ActionMap actionMap = list.getActionMap();
// 绑定 Delete/Backspace 键到删除操作
KeyStroke deleteKey = KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0);
KeyStroke backspaceKey = KeyStroke.getKeyStroke(KeyEvent.VK_BACK_SPACE, 0);
inputMap.put(deleteKey, "deleteLayer");
inputMap.put(backspaceKey, "deleteLayer");
Action deleteAction = new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
onRemoveLayer();
}
};
actionMap.put("deleteLayer", deleteAction);
}
private JList<ModelPart> createModernList() {
JList<ModelPart> list = new JList<>(listModel);
// 【修正 1启用多选
// 启用多选
list.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
list.setBackground(SURFACE_COLOR);
list.setForeground(TEXT_COLOR);
@@ -205,8 +256,10 @@ public class ModelLayerPanel extends JPanel {
cellRenderer.attachMouseListener(list, listModel);
list.setCellRenderer(cellRenderer);
list.setDragEnabled(true);
// 【修正 2使用多选 TransferHandler】
list.setTransferHandler(new LayerReorderTransferHandler(this));
// FIX: 使用新的复合 TransferHandler 统一处理文件拖放和内部重排
list.setTransferHandler(new CompositeLayerTransferHandler(this));
list.setDropMode(DropMode.INSERT);
list.addMouseListener(new MouseAdapter() {
@Override
@@ -436,7 +489,7 @@ public class ModelLayerPanel extends JPanel {
/**
* 【新增方法】执行多选拖拽后的图层块重排序操作。
* 供 LayerReorderTransferHandler 调用。
* 供 CompositeLayerTransferHandler (原 LayerReorderTransferHandler) 调用。
* @param srcIndices 列表中的视觉源索引数组(从上到下,已排序)。
* @param dropIndex 列表中的视觉目标插入索引。
*/
@@ -486,7 +539,7 @@ public class ModelLayerPanel extends JPanel {
}
removeButton.setEnabled(hasSelection);
// 【修正 3多选时启用上下移动按钮
// 多选时启用上下移动按钮
upButton.setEnabled(hasSelection);
downButton.setEnabled(hasSelection);
// 绑定贴图仍然只在单选时有意义
@@ -608,9 +661,10 @@ public class ModelLayerPanel extends JPanel {
}
private void createEmptyPart() {
String name = JOptionPane.showInputDialog(this, "新图层名称:", "新图层");
String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称:", "新图层");
if (name == null || name.trim().isEmpty()) return;
// 传入名称
operationManager.addLayer(name);
reloadFromModel();
@@ -634,7 +688,7 @@ public class ModelLayerPanel extends JPanel {
private void showRenameDialog(ModelPart part) {
String newName = (String) JOptionPane.showInputDialog(
this,
SwingUtilities.getWindowAncestor(this),
"输入新名称:",
"重命名图层",
JOptionPane.PLAIN_MESSAGE,
@@ -793,7 +847,19 @@ public class ModelLayerPanel extends JPanel {
renderPanel.getGlContextManager().executeInGLContext(() -> {
try {
Texture texture = Texture.createFromFile(texName, filePath);
meshToBind.setTexture(texture);
List<Mesh2D> partMeshes = sel.getMeshes();
Mesh2D actualMesh = null;
if (partMeshes != null && !partMeshes.isEmpty()) {
actualMesh = partMeshes.get(partMeshes.size() - 1);
}
if (actualMesh != null) {
actualMesh.setTexture(texture);
} else {
// 修复:将无法解析的 'mesh' 替换为正确的局部变量 'meshToBind'
meshToBind.setTexture(texture);
}
model.addTexture(texture);
model.markNeedsUpdate();
} catch (Throwable ex) {
@@ -822,27 +888,32 @@ public class ModelLayerPanel extends JPanel {
}
private void onRemoveLayer() {
// 修正:支持删除多个选中的图层
List<ModelPart> selectedParts = layerList.getSelectedValuesList();
if (selectedParts.isEmpty()) return;
String names = selectedParts.stream().map(ModelPart::getName).collect(java.util.stream.Collectors.joining(""));
int r = JOptionPane.showConfirmDialog(this, "确认删除图层:" + names + " ?", "确认删除", JOptionPane.YES_NO_OPTION);
int r = JOptionPane.showConfirmDialog(SwingUtilities.getWindowAncestor(this), "确认删除图层:" + names + " ?", "确认删除", JOptionPane.YES_NO_OPTION);
if (r != JOptionPane.YES_OPTION) return;
try {
for(ModelPart part : selectedParts) {
operationManager.removeLayer(part);
thumbnailManager.removeThumbnail(part);
// 仅移除第一个选中项的参数管理(这是一个简化,实际应用中可能需要遍历移除)
if (part == selectedParts.get(0)) {
renderPanel.getParametersManagement().removeParameter(part, "all");
renderPanel.getGlContextManager().executeInGLContext(() -> renderPanel.getParametersManagement().removeParameter(part, "all"));
if (renderPanel != null && renderPanel.getParametersManagement() != null) {
renderPanel.getParametersManagement().removeParameter(part, "all");
renderPanel.getGlContextManager().executeInGLContext(() -> {
if (renderPanel != null && renderPanel.getParametersManagement() != null) {
renderPanel.getParametersManagement().removeParameter(part, "all");
}
});
}
}
}
reloadFromModel();
} catch (Exception ex) {
JOptionPane.showMessageDialog(this, "删除失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
// 修复:将父组件改为顶层窗口
JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(this), "删除失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
}
}
@@ -900,12 +971,57 @@ public class ModelLayerPanel extends JPanel {
try {
BufferedImage img = ImageIO.read(f);
if (img == null) throw new IOException("无法读取图片:" + f.getAbsolutePath());
String name = JOptionPane.showInputDialog(this, "新图层名称:", f.getName());
// 1. 获取用户输入的名称(或默认文件名)
String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称:", f.getName());
if (name == null || name.trim().isEmpty()) name = f.getName();
// --- 修复:确保图层名称唯一性 start ---
// 解决 "Part already exists" 错误,确保 ModelPart 名称唯一
Map<String, ModelPart> partMap = getModelPartMap();
if (partMap != null) {
String uniqueName = name;
int counter = 1;
// 分离文件名和扩展名
String nameWithoutExt = name;
String extension = "";
int dotIndex = name.lastIndexOf('.');
if (dotIndex > 0) { // 确保点不在开头
nameWithoutExt = name.substring(0, dotIndex);
extension = name.substring(dotIndex);
}
// 剥离已有的 (数字) 部分,从干净的基础名开始计数
String baseNameForCounter = nameWithoutExt;
int bracketStart = baseNameForCounter.lastIndexOf('(');
int bracketEnd = baseNameForCounter.lastIndexOf(')');
if (bracketStart > 0 && bracketEnd == baseNameForCounter.length() - 1) {
try {
// 检查括号中的内容是否为数字
Integer.parseInt(baseNameForCounter.substring(bracketStart + 1, bracketEnd).trim());
baseNameForCounter = baseNameForCounter.substring(0, bracketStart).trim();
} catch (NumberFormatException ignored) {
// 不是数字,则保持不变
}
}
// 检查并生成唯一名称
while (partMap.containsKey(uniqueName)) {
uniqueName = baseNameForCounter + " (" + counter + ")" + extension;
counter++;
if (counter > 100) { // 避免无限循环
throw new IllegalStateException("无法生成唯一图层名称。");
}
}
name = uniqueName; // 使用最终的唯一名称
}
// --- 修复:确保图层名称唯一性 end ---
ModelPart part = model.createPart(name);
Mesh2D mesh = MeshTextureUtil.createQuadForImage(img, name + "_mesh");
mesh.createDefaultSecondaryVertices();
// 修复上一个问题中 GL Context lambda 无法解析 'mesh' 的编译错误
final Mesh2D mesh = MeshTextureUtil.createQuadForImage(img, name + "_mesh");
part.addMesh(mesh);
if (renderPanel != null) {
@@ -970,11 +1086,11 @@ public class ModelLayerPanel extends JPanel {
}
private void createPartWithTransparentTexture() {
String name = JOptionPane.showInputDialog(this, "新图层名称(透明):", "透明图层");
String name = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "新图层名称(透明):", "透明图层");
if (name == null || name.trim().isEmpty()) return;
int w = 128, h = 128;
try {
String wh = JOptionPane.showInputDialog(this, "输入尺寸宽x高例如 128x128或留空使用 128x128", "128x128");
String wh = JOptionPane.showInputDialog(SwingUtilities.getWindowAncestor(this), "输入尺寸宽x高例如 128x128或留空使用 128x128", "128x128");
if (wh != null && wh.contains("x")) {
String[] sp = wh.split("x");
w = Math.max(1, Integer.parseInt(sp[0].trim()));
@@ -995,6 +1111,8 @@ public class ModelLayerPanel extends JPanel {
}
model.markNeedsUpdate();
// 传入名称
operationManager.addLayer(part.getName());
reloadFromModel();
selectPart(part);
thumbnailManager.generateThumbnail(part);
@@ -1010,55 +1128,84 @@ public class ModelLayerPanel extends JPanel {
// ====================================================================
/**
* 【新增】处理外部文件拖放的 TransferHandler
* 支持拖放单个 .psd 或图片文件来创建图层
* 【新类】复合拖放处理器:统一处理外部文件拖放和内部图层重排
* 将其设置给 JList (layerList),以确保它优先于 JScrollPane 捕获事件
*/
private class FileDropTransferHandler extends TransferHandler {
private class CompositeLayerTransferHandler extends TransferHandler {
private final LayerReorderTransferHandler internalReorderHandler;
private final List<String> IMAGE_EXTENSIONS = List.of("png", "jpg", "jpeg");
private static final String PSD_EXTENSION = "psd";
public CompositeLayerTransferHandler(ModelLayerPanel panel) {
// 内部图层重排处理器实例
this.internalReorderHandler = new LayerReorderTransferHandler(panel);
}
@Override
public int getSourceActions(JComponent c) {
// 委托给内部处理器处理拖出操作 (内部重排)
return internalReorderHandler.getSourceActions(c);
}
@Override
public Transferable createTransferable(JComponent c) {
// 委托给内部处理器处理拖出数据 (内部重排)
return internalReorderHandler.createTransferable(c);
}
@Override
public boolean canImport(TransferSupport support) {
// 检查是否支持文件列表数据格式
return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor);
// 1. 检查是否为外部文件拖放 (文件列表 DataFlavor) - 优先处理
if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
return true;
}
// 2. 否则,委托给内部处理器处理 (图层重排)
return internalReorderHandler.canImport(support);
}
@Override
public boolean importData(TransferSupport support) {
if (!canImport(support)) return false;
// 1. 处理外部文件拖放
if (support.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
try {
@SuppressWarnings("unchecked")
Transferable t = support.getTransferable();
List<File> files = (List<File>) t.getTransferData(DataFlavor.javaFileListFlavor);
try {
@SuppressWarnings("unchecked")
List<File> files = (List<File>) support.getTransferable().getTransferData(DataFlavor.javaFileListFlavor);
if (files.size() != 1) {
JOptionPane.showMessageDialog(ModelLayerPanel.this, "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE);
return false;
}
if (files.size() != 1) {
// 仅支持拖放单个文件
JOptionPane.showMessageDialog(ModelLayerPanel.this, "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE);
File file = files.get(0);
String fileName = file.getName().toLowerCase();
String extension = "";
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex != -1) {
extension = fileName.substring(dotIndex + 1);
}
if (PSD_EXTENSION.equals(extension)) {
psdImporter.importPSDFile(file);
return true;
} else if (IMAGE_EXTENSIONS.contains(extension)) {
createPartWithTextureFromFile(file);
return true;
} else {
JOptionPane.showMessageDialog(ModelLayerPanel.this, "不支持的文件类型: ." + extension, "导入失败", JOptionPane.WARNING_MESSAGE);
return false;
}
} catch (Exception ex) {
JOptionPane.showMessageDialog(ModelLayerPanel.this, "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
ex.printStackTrace();
return false;
}
File file = files.get(0);
String fileName = file.getName().toLowerCase();
String extension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (PSD_EXTENSION.equals(extension)) {
// 导入 PSD 文件
psdImporter.importPSDFile(file);
return true;
} else if (IMAGE_EXTENSIONS.contains(extension)) {
// 创建带贴图的图层
createPartWithTextureFromFile(file);
return true;
} else {
JOptionPane.showMessageDialog(ModelLayerPanel.this, "不支持的文件类型: ." + extension, "导入失败", JOptionPane.WARNING_MESSAGE);
return false;
}
} catch (Exception ex) {
JOptionPane.showMessageDialog(ModelLayerPanel.this, "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
ex.printStackTrace();
return false;
}
// 2. 否则,委托给内部处理器处理 (图层重排)
return internalReorderHandler.importData(support);
}
}
@@ -1218,4 +1365,78 @@ public class ModelLayerPanel extends JPanel {
return new Dimension(14, 14);
}
}
/**
* 【只处理外部文件拖放】的处理器。
* 将其设置给顶层窗口的内容面板。
*/
private class FileDropOnlyTransferHandler extends TransferHandler {
private final List<String> IMAGE_EXTENSIONS = List.of("png", "jpg", "jpeg");
private static final String PSD_EXTENSION = "psd";
private static final String MODEL_EXTENSION = "model";
@Override
public boolean canImport(TransferSupport support) {
return support.isDataFlavorSupported(DataFlavor.javaFileListFlavor);
}
@Override
public boolean importData(TransferSupport support) {
if (!canImport(support)) {
return false;
}
try {
Transferable t = support.getTransferable();
@SuppressWarnings("unchecked")
List<File> files = (List<File>) t.getTransferData(DataFlavor.javaFileListFlavor);
if (files.size() != 1) {
JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "仅支持拖放单个文件。", "导入失败", JOptionPane.WARNING_MESSAGE);
return false;
}
final File file = files.get(0);
String fileName = file.getName().toLowerCase();
String extension = "";
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex != -1) {
extension = fileName.substring(dotIndex + 1);
}
final String finalExtension = extension;
SwingUtilities.invokeLater(() -> {
if (MODEL_EXTENSION.equals(finalExtension)) {
Window window = SwingUtilities.getWindowAncestor(ModelLayerPanel.this);
if (window instanceof MainWindow mainWindow) {
if (mainWindow.shouldAskUserToSave()) {
int confirm = JOptionPane.showConfirmDialog(
mainWindow,
"当前模型已修改。加载新模型 " + file.getName() + " 前是否保存更改?",
"加载模型确认",
JOptionPane.YES_NO_CANCEL_OPTION
);
if (confirm == JOptionPane.CANCEL_OPTION) {
return;
}
if (confirm == JOptionPane.YES_OPTION) {
mainWindow.saveData(false);
mainWindow.loadModel(file.getAbsolutePath());
}
}
} else {
JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "无法获取主窗口引用,无法加载模型文件。", "导入失败", JOptionPane.ERROR_MESSAGE);
}
} else if (PSD_EXTENSION.equals(finalExtension)) {
psdImporter.importPSDFile(file);
} else if (IMAGE_EXTENSIONS.contains(finalExtension)) {
createPartWithTextureFromFile(file);
} else {
JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "不支持的文件类型: ." + finalExtension, "导入失败", JOptionPane.WARNING_MESSAGE);
}
});
return true;
} catch (Exception ex) {
JOptionPane.showMessageDialog(SwingUtilities.getWindowAncestor(ModelLayerPanel.this), "文件导入失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE);
ex.printStackTrace();
return false;
}
}
}
}

View File

@@ -387,6 +387,16 @@ public class ModelRenderPanel extends JPanel {
final float[][] modelCoords = {worldManagement.screenToModelCoordinates(e.getX(), e.getY())};
float modelX = modelCoords[0][0];
float modelY = modelCoords[0][1];
for (ModelClickListener listener : clickListeners) {
try {
listener.onModelHover(getSelectedMesh(), modelX, modelY, e.getX(), e.getY());
} catch (Exception ex) {
logger.error("点击事件监听器执行出错", ex);
}
}
// 如果有激活的工具,优先交给工具处理
if (toolManagement.hasActiveTool() && modelCoords[0] != null) {
glContextManager.executeInGLContext(() -> toolManagement.handleMouseDragged(e, modelCoords[0][0], modelCoords[0][1]));

View File

@@ -94,11 +94,7 @@ public class KeyboardManager {
logger.info("{}摄像机", newState ? "启用" : "禁用");
}
});
// 注册工具快捷键
registerToolShortcuts();
// 设置键盘监听器
setupKeyListeners();
}
@@ -106,17 +102,6 @@ public class KeyboardManager {
* 注册工具快捷键
*/
private void registerToolShortcuts() {
// 木偶变形工具快捷键Ctrl+P
registerShortcut("puppetTool", KeyStroke.getKeyStroke(KeyEvent.VK_P, KeyEvent.CTRL_DOWN_MASK),
new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
panel.switchTool("木偶变形工具");
logger.info("切换到木偶变形工具");
}
});
// 顶点变形工具快捷键Ctrl+T
registerShortcut("vertexTool", KeyStroke.getKeyStroke(KeyEvent.VK_T, KeyEvent.CTRL_DOWN_MASK),
new AbstractAction() {
@Override
@@ -125,16 +110,6 @@ public class KeyboardManager {
logger.info("切换到顶点变形工具");
}
});
// 选择工具快捷键Ctrl+S
registerShortcut("selectionTool", KeyStroke.getKeyStroke(KeyEvent.VK_S, KeyEvent.CTRL_DOWN_MASK),
new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
panel.switchTool("选择工具");
logger.info("切换到选择工具");
}
});
}
/**

View File

@@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory;
import javax.swing.tree.DefaultMutableTreeNode;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 2D模型部件支持层级变换和变形器
@@ -50,7 +51,7 @@ public class ModelPart {
private boolean boundsDirty;
private boolean pivotInitialized;
private final List<ModelEvent> events = new ArrayList<>();
private final List<ModelEvent> events = new CopyOnWriteArrayList<>();
private boolean inMultiSelectionOperation = false;
// ====== 液化模式枚举 ======

View File

@@ -678,7 +678,7 @@ public class Mesh2D {
public boolean setOriginalPivot(Vector2f p) {
if (p != null) {
BoundingBox bounds = getBounds();
BoundingBox bounds = calculateOriginalBounds();
if (bounds != null &&
p.x >= bounds.getMinX() && p.x <= bounds.getMaxX() &&
p.y >= bounds.getMinY() && p.y <= bounds.getMaxY()) {

View File

@@ -0,0 +1,82 @@
package com.chuangzhou.vivid2D.window;
import javax.swing.*;
import java.awt.event.ActionEvent;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
/**
* 负责管理主窗口的全局快捷键。
*/
public class KeyBindingManager {
// Action 名称常量
private static final String ACTION_SAVE = "saveAction";
private static final String ACTION_SAVE_AS = "saveAsAction";
private final JRootPane rootPane;
private final MainWindow mainWindow;
public KeyBindingManager(MainWindow mainWindow) {
this.mainWindow = mainWindow;
this.rootPane = mainWindow.getRootPane();
setupGlobalKeyBindings();
}
/**
* 设置全局快捷键绑定到 RootPane。
* 使用 JComponent.WHEN_IN_FOCUSED_WINDOW 确保在窗口获得焦点时生效。
*/
private void setupGlobalKeyBindings() {
// 获取 InputMap 和 ActionMap
InputMap inputMap = rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
ActionMap actionMap = rootPane.getActionMap();
// 绑定动作
actionMap.put(ACTION_SAVE, new SaveAction());
actionMap.put(ACTION_SAVE_AS, new SaveAsAction());
// 绑定快捷键 KeyStroke
bindKey(inputMap, KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK, ACTION_SAVE);
bindKey(inputMap, KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK, ACTION_SAVE_AS);
}
/**
* 辅助方法:简化 KeyStroke 和 Action 的绑定。
*/
public void bindKey(InputMap inputMap, int keyCode, int modifiers, String actionName) {
KeyStroke key = KeyStroke.getKeyStroke(keyCode, modifiers);
inputMap.put(key, actionName);
}
/**
* 内部类Ctrl + S 保存动作。
*/
private class SaveAction extends AbstractAction {
@Override
public void actionPerformed(ActionEvent e) {
// 调用 MainWindow 的保存方法 (不退出)
mainWindow.saveData(false);
}
}
/**
* 内部类Ctrl + Shift + S 另存为动作。
*/
private class SaveAsAction extends AbstractAction {
@Override
public void actionPerformed(ActionEvent e) {
// 另存为操作:强制进入 "另存为" 逻辑
String originalPath = mainWindow.currentModelPath;
// 临时将路径设为 null
mainWindow.currentModelPath = null;
mainWindow.saveData(false);
// 如果用户取消另存为 (saveData 中 currentModelPath 仍为 null),恢复原来的路径
if (mainWindow.currentModelPath == null) {
mainWindow.currentModelPath = originalPath;
}
}
}
}

View File

@@ -8,12 +8,10 @@ import com.chuangzhou.vivid2D.render.awt.manager.data.ParametersManagementData;
import com.chuangzhou.vivid2D.render.model.Model2D;
import com.chuangzhou.vivid2D.render.model.ModelPart;
import com.chuangzhou.vivid2D.render.model.util.Mesh2D;
import com.formdev.flatlaf.themes.FlatMacDarkLaf;
import jnafilechooser.api.JnaFileChooser;
import org.jetbrains.annotations.NotNull;
import javax.swing.*;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.*;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
@@ -37,9 +35,11 @@ public class MainWindow extends JFrame {
private final TransformPanel transformPanel;
private final ParametersPanel parametersPanel;
private final ModelPartInfoPanel partInfoPanel;
private String currentModelPath = null;
private final KeyBindingManager keyBindingManager;
public String currentModelPath = null;
private JLabel statusBarLabel;
private JMenuBar menuBar;
private boolean isModelModified = false;
/**
* 构造主窗口。
@@ -61,6 +61,7 @@ public class MainWindow extends JFrame {
setupInitialListeners();
setSize(1600, 900);
setLocationRelativeTo(null);
keyBindingManager = new KeyBindingManager(this);
}
/**
@@ -118,6 +119,7 @@ public class MainWindow extends JFrame {
statusBarLabel.setText("新模型 " + finalModelName + " 创建并加载完毕。");
setEditComponentsEnabled(true);
layerPanel.setModel(newModel);
setModelModified(false);
} catch (Exception e) {
System.err.println("新建模型加载失败: " + e.getMessage());
currentModelPath = null;
@@ -215,21 +217,21 @@ public class MainWindow extends JFrame {
addWindowListener(new WindowAdapter() {
@Override
public void windowClosing(WindowEvent e) {
if (currentModelPath == null) {
shutdown();
return;
}
int confirm = JOptionPane.showConfirmDialog(
MainWindow.this,
"是否在退出前保存更改?",
"退出确认",
JOptionPane.YES_NO_CANCEL_OPTION
);
if (confirm == JOptionPane.CANCEL_OPTION) {
return;
}
if (confirm == JOptionPane.YES_OPTION) {
saveData(true);
if (shouldAskUserToSave()) {
int confirm = JOptionPane.showConfirmDialog(
MainWindow.this,
"模型已修改。是否在退出前保存更改?",
"退出确认",
JOptionPane.YES_NO_CANCEL_OPTION
);
if (confirm == JOptionPane.CANCEL_OPTION) {
return;
}
if (confirm == JOptionPane.YES_OPTION) {
saveData(true);
} else {
shutdown();
}
} else {
shutdown();
}
@@ -244,6 +246,7 @@ public class MainWindow extends JFrame {
layerPanel.setSelectedLayers(selectedPart);
transformPanel.setSelectedParts(selectedPart);
if (!selectedPart.isEmpty()) {
setModelModified(true);
partInfoPanel.updatePanel(selectedPart.get(0));
} else {
partInfoPanel.updatePanel(null);
@@ -308,7 +311,7 @@ public class MainWindow extends JFrame {
/**
* 加载模型并更新 UI 状态。
*/
private void loadModel(String modelPath) {
public void loadModel(String modelPath) {
setEditComponentsEnabled(false);
statusBarLabel.setText("正在加载模型: " + modelPath);
CompletableFuture.runAsync(() -> {
@@ -337,6 +340,7 @@ public class MainWindow extends JFrame {
statusBarLabel.setText("模型加载完毕。");
setEditComponentsEnabled(true);
layerPanel.setModel(finalModel);
setModelModified(false);
}
});
});
@@ -346,28 +350,16 @@ public class MainWindow extends JFrame {
* 保存模型和参数数据。
* @param exitOnComplete 如果为 true则在保存后调用 shutdown()。
*/
private void saveData(boolean exitOnComplete) {
public void saveData(boolean exitOnComplete) {
if (currentModelPath == null) {
JnaFileChooser jnaFileChooser = new JnaFileChooser();
jnaFileChooser.setTitle("另存为 Vivid2D 模型文件 (*.model)");
// JnaFileChooser 使用 addFilter() 来添加过滤器
jnaFileChooser.addFilter("Vivid2D 模型文件 (*.model)", "model");
jnaFileChooser.setMultiSelectionEnabled(false);
jnaFileChooser.setOpenButtonText("保存");
jnaFileChooser.setMode(JnaFileChooser.Mode.Files);
// 弹出保存对话框
JnaFileChooser jnaFileChooser = getJnaFileChooser();
if (jnaFileChooser.showSaveDialog(this)) {
File fileToSave = jnaFileChooser.getSelectedFile();
String path = fileToSave.getAbsolutePath();
// 确保文件以 .model 结尾 (原生对话框可能已经处理,但 Swing 风格代码保留以防万一)
if (!path.toLowerCase().endsWith(".model")) {
path += ".model";
fileToSave = new File(path);
}
this.currentModelPath = path;
setTitle("Vivid2D Editor - " + fileToSave.getName());
} else {
@@ -393,11 +385,57 @@ public class MainWindow extends JFrame {
statusBarLabel.setText("保存参数失败!");
}
statusBarLabel.setText("保存成功。");
setModelModified(false);
if (exitOnComplete) {
shutdown();
}
}
private @NotNull JnaFileChooser getJnaFileChooser() {
JnaFileChooser jnaFileChooser = new JnaFileChooser();
jnaFileChooser.setTitle("另存为 Vivid2D 模型文件 (*.model)");
jnaFileChooser.addFilter("Vivid2D 模型文件 (*.model)", "model");
jnaFileChooser.setMultiSelectionEnabled(false);
jnaFileChooser.setOpenButtonText("保存");
jnaFileChooser.setMode(JnaFileChooser.Mode.Files);
String defaultFileName;
Model2D currentModel = renderPanel.getModel();
if (currentModel != null && currentModel.getName() != null && !currentModel.getName().trim().isEmpty()) {
defaultFileName = currentModel.getName() + ".model";
} else {
defaultFileName = "model.model";
}
jnaFileChooser.setDefaultFileName(defaultFileName);
return jnaFileChooser;
}
/**
* 获取模型是否已修改的状态。
*/
public boolean isModelModified() {
return isModelModified;
}
/**
* 编辑面板在进行任何修改操作时应调用 setModelModified(true)。
* 保存或加载成功后应调用 setModelModified(false)。
*/
public void setModelModified(boolean modified) {
this.isModelModified = modified;
}
/**
* 判断当前是否需要询问用户保存模型。
* 只要模型被修改过,就应该询问。
*/
public boolean shouldAskUserToSave() {
return isModelModified;
}
public KeyBindingManager getKeyBindingManager() {
return keyBindingManager;
}
/**
* 清理资源并退出应用程序。
*/