diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java index 7527d039..ab20d5a3 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/graph/ExportObjectAction.java @@ -36,7 +36,7 @@ import static java.nio.file.StandardOpenOption.*; -@ActionRegistration(id = ExportObjectAction.ID, text = "&Export As\u2026", icon = "fugue:blue-document-export", keystroke = "ctrl E") +@ActionRegistration(id = ExportObjectAction.ID, text = "&Export As\u2026", description = "Export selected resources in a supported format", icon = "fugue:blue-document-export", keystroke = "ctrl E") @ActionContribution(parent = BookmarkMenu.ID, group = MenuIds.GROUP_EXPORT) @ActionContribution(parent = EditorMenu.ID, group = MenuIds.GROUP_EXPORT) @ActionContribution(parent = GraphMenu.ID, group = MenuIds.GROUP_EXPORT) diff --git a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java index bf78669a..07d150a7 100644 --- a/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java +++ b/odradek-app/src/main/java/sh/adelessfox/odradek/app/ui/menu/main/file/OpenGraphAction.java @@ -9,7 +9,7 @@ import sh.adelessfox.odradek.ui.actions.ActionContribution; import sh.adelessfox.odradek.ui.actions.ActionRegistration; -@ActionRegistration(text = "Open Streaming Graph", description = "Opens the streaming graph resource") +@ActionRegistration(text = "Open Streaming Graph", description = "Open the streaming graph resource") @ActionContribution(parent = MainMenu.File.ID) public class OpenGraphAction extends Action { @Override diff --git a/odradek-ui/src/main/java/module-info.java b/odradek-ui/src/main/java/module-info.java index a780105e..5979801d 100644 --- a/odradek-ui/src/main/java/module-info.java +++ b/odradek-ui/src/main/java/module-info.java @@ -13,6 +13,7 @@ exports sh.adelessfox.odradek.ui.actions; exports sh.adelessfox.odradek.ui.components.laf; + exports sh.adelessfox.odradek.ui.components.properties; exports sh.adelessfox.odradek.ui.components.tool; exports sh.adelessfox.odradek.ui.components.tree; exports sh.adelessfox.odradek.ui.components; diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/actions/Actions.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/actions/Actions.java index e522e0d0..e3494ba7 100644 --- a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/actions/Actions.java +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/actions/Actions.java @@ -2,6 +2,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sh.adelessfox.odradek.ui.components.SplitButton; import sh.adelessfox.odradek.ui.data.DataContext; import sh.adelessfox.odradek.ui.util.Icons; import sh.adelessfox.odradek.util.system.OperatingSystem; @@ -13,6 +14,7 @@ import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.beans.PropertyChangeListener; import java.lang.reflect.Field; import java.util.*; import java.util.function.Function; @@ -42,7 +44,7 @@ public static void installContextMenu(JComponent component, String id, DataConte } public static JToolBar createToolBar(String id, DataContext context) { - var toolBar = new JToolBar(); + var toolBar = new ActionToolBar(); contributeGroups(toolBar, new ToolBarActionContributor(), id, new ActionContext(context, null, null)); return toolBar; } @@ -389,12 +391,13 @@ public PopupMenuAction( super(descriptor, context); } - @SuppressWarnings("unchecked") @Override public void actionPerformed(ActionEvent e) { - var popupMenu = createPopupMenu(component, descriptor.id(), context, includeSourceAction); - var selectionProvider = getSelectionProvider(component); - showPopupMenu(component, popupMenu, e, (SelectionProvider) selectionProvider); + // Handled by ActionToolBar#createActionComponent + } + + JPopupMenu createPopupMenu() { + return Actions.createPopupMenu(component, descriptor.id(), context, includeSourceAction); } } @@ -623,4 +626,30 @@ private interface ActionContributor { T createItem(C component, AbstractMenuAction action); } + + private static class ActionToolBar extends JToolBar { + @Override + protected JButton createActionComponent(javax.swing.Action a) { + if (a instanceof PopupMenuAction action) { + var button = new SplitButton() { + @Override + protected PropertyChangeListener createActionPropertyChangeListener(javax.swing.Action a) { + PropertyChangeListener pcl = createActionChangeListener(this); + if (pcl == null) { + pcl = super.createActionPropertyChangeListener(a); + } + return pcl; + } + }; + button.setPopupMenu(action.createPopupMenu()); + if (a.getValue(javax.swing.Action.SMALL_ICON) != null | + a.getValue(javax.swing.Action.LARGE_ICON_KEY) != null + ) { + button.setHideActionText(true); + } + return button; + } + return super.createActionComponent(a); + } + } } diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/LabeledSeparator.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/LabeledSeparator.java new file mode 100644 index 00000000..3fc92a70 --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/LabeledSeparator.java @@ -0,0 +1,47 @@ +package sh.adelessfox.odradek.ui.components; + +import javax.swing.*; +import java.util.Objects; + +public class LabeledSeparator extends JSeparator { + private String label; + + public LabeledSeparator(String label) { + this(label, HORIZONTAL); + } + + public LabeledSeparator(String label, int orientation) { + super(orientation); + setLabel(label); + } + + @Override + public String getUIClassID() { + return "LabeledSeparatorUI"; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + final String oldLabel = this.label; + + if (!Objects.equals(oldLabel, label)) { + this.label = label; + + firePropertyChange("label", oldLabel, label); + revalidate(); + repaint(); + } + } + + @Override + public void setOrientation(int orientation) { + if (orientation != HORIZONTAL) { + throw new IllegalArgumentException("orientation must be HORIZONTAL"); + } + + super.setOrientation(orientation); + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/SplitButton.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/SplitButton.java new file mode 100644 index 00000000..af2068cf --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/SplitButton.java @@ -0,0 +1,58 @@ +package sh.adelessfox.odradek.ui.components; + +import javax.swing.*; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; + +public class SplitButton extends JButton { + private final PopupMenuListener popupMenuListener = createPopupMenuListener(); + private JPopupMenu popupMenu; + + public SplitButton() { + setHorizontalAlignment(SwingConstants.LEFT); + setPopupMenu(new JPopupMenu()); + addActionListener(_ -> showPopupMenu()); + } + + @Override + public String getUIClassID() { + return "SplitButtonUI"; + } + + public JPopupMenu getPopupMenu() { + return popupMenu; + } + + public void setPopupMenu(JPopupMenu popupMenu) { + if (this.popupMenu != popupMenu) { + if (this.popupMenu != null) { + this.popupMenu.removePopupMenuListener(popupMenuListener); + } + this.popupMenu = popupMenu; + popupMenu.addPopupMenuListener(popupMenuListener); + } + } + + private void showPopupMenu() { + popupMenu.show(this, 0, getHeight()); + } + + private PopupMenuListener createPopupMenuListener() { + return new PopupMenuListener() { + @Override + public void popupMenuWillBecomeVisible(PopupMenuEvent e) { + getModel().setPressed(true); + } + + @Override + public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { + getModel().setPressed(false); + } + + @Override + public void popupMenuCanceled(PopupMenuEvent e) { + getModel().setPressed(false); + } + }; + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatLabeledSeparatorUI.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatLabeledSeparatorUI.java new file mode 100644 index 00000000..6a390d15 --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatLabeledSeparatorUI.java @@ -0,0 +1,92 @@ +package sh.adelessfox.odradek.ui.components.laf; + +import com.formdev.flatlaf.ui.FlatSeparatorUI; +import com.formdev.flatlaf.ui.FlatUIUtils; +import com.formdev.flatlaf.util.UIScale; +import sh.adelessfox.odradek.ui.components.LabeledSeparator; +import sh.adelessfox.odradek.ui.util.GraphicsUtils; + +import javax.swing.*; +import javax.swing.plaf.ComponentUI; +import java.awt.*; +import java.awt.geom.Rectangle2D; + +public final class FlatLabeledSeparatorUI extends FlatSeparatorUI { + private Color labelForeground; + private boolean defaultsInitialized = false; + + private FlatLabeledSeparatorUI(boolean shared) { + super(shared); + } + + public static ComponentUI createUI(JComponent c) { + return FlatUIUtils.canUseSharedUI(c) + ? FlatUIUtils.createSharedUI(FlatLabeledSeparatorUI.class, () -> new FlatLabeledSeparatorUI(true)) + : new FlatLabeledSeparatorUI(false); + } + + @Override + protected void installDefaults(JSeparator s) { + super.installDefaults(s); + + if (!defaultsInitialized) { + labelForeground = UIManager.getColor("LabeledSeparator.labelForeground"); + defaultsInitialized = true; + } + } + + @Override + protected void uninstallDefaults(JSeparator s) { + super.uninstallDefaults(s); + defaultsInitialized = false; + } + + @Override + public void paint(Graphics g, JComponent c) { + var g2 = (Graphics2D) g.create(); + try { + String label = ((LabeledSeparator) c).getLabel(); + float width = UIScale.scale((float) stripeWidth); + float indent = UIScale.scale((float) stripeIndent); + float height = getHeight(c); + float shift; + + if (label != null && !label.isEmpty()) { + var metrics = c.getFontMetrics(g.getFont()); + + GraphicsUtils.setTextRenderingHints(g2); + g2.setColor(labelForeground); + g2.drawString(label, indent, metrics.getAscent()); + + shift = metrics.stringWidth(label) + UIScale.scale(5f); + } else { + shift = 0; + } + + FlatUIUtils.setRenderingHints(g2); + g2.setColor(c.getForeground()); + g2.fill(new Rectangle2D.Float(shift, indent + height / 2, c.getWidth() - shift, width)); + } finally { + g2.dispose(); + } + } + + @Override + public Dimension getPreferredSize(JComponent c) { + return new Dimension(0, getHeight(c)); + } + + @Override + public Dimension getMinimumSize(JComponent c) { + return getPreferredSize(c); + } + + private int getHeight(JComponent c) { + var label = ((LabeledSeparator) c).getLabel(); + if (label != null && !label.isEmpty()) { + return c.getFontMetrics(c.getFont()).getHeight(); + } else { + return UIScale.scale(this.height); + } + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatSplitButtonUI.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatSplitButtonUI.java new file mode 100644 index 00000000..cf354cd3 --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/laf/FlatSplitButtonUI.java @@ -0,0 +1,69 @@ +package sh.adelessfox.odradek.ui.components.laf; + +import com.formdev.flatlaf.ui.FlatButtonUI; +import com.formdev.flatlaf.ui.FlatUIUtils; +import com.formdev.flatlaf.util.UIScale; + +import javax.swing.*; +import javax.swing.plaf.ComponentUI; +import java.awt.*; + +public final class FlatSplitButtonUI extends FlatButtonUI { + private Color arrowColor; + private int arrowWidth; + private int arrowGap; + + private boolean defaultsInitialized = false; + + private FlatSplitButtonUI(boolean shared) { + super(shared); + } + + public static ComponentUI createUI(JComponent c) { + return FlatUIUtils.canUseSharedUI(c) + ? FlatUIUtils.createSharedUI(FlatSplitButtonUI.class, () -> new FlatSplitButtonUI(true)) + : new FlatSplitButtonUI(false); + } + + @Override + protected void installDefaults(AbstractButton b) { + super.installDefaults(b); + + if (!defaultsInitialized) { + arrowColor = UIManager.getColor("SplitButton.arrowColor"); + arrowWidth = UIManager.getInt("SplitButton.arrowWidth"); + arrowGap = UIManager.getInt("SplitButton.arrowGap"); + defaultsInitialized = true; + } + } + + @Override + protected void uninstallDefaults(AbstractButton b) { + super.uninstallDefaults(b); + defaultsInitialized = false; + } + + @Override + public void paint(Graphics g, JComponent c) { + super.paint(g, c); + paintArrow(g, c); + } + + private void paintArrow(Graphics g, JComponent c) { + int x = c.getWidth() - c.getInsets().right - UIScale.scale(Math.ceilDiv(arrowWidth, 2)); + int y = c.getHeight() / 2; + + var g2 = (Graphics2D) g.create(); + FlatUIUtils.setRenderingHints(g2); + g2.setColor(arrowColor); + FlatUIUtils.paintArrow(g2, x, y, 0, 0, SwingConstants.SOUTH, true, arrowWidth, 1, 0, 0); + g2.dispose(); + } + + @Override + public Dimension getPreferredSize(JComponent c) { + Dimension size = super.getPreferredSize(c); + size.width += UIScale.scale(arrowWidth + arrowGap); + return size; + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/BooleanProperty.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/BooleanProperty.java new file mode 100644 index 00000000..b3bca9ec --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/BooleanProperty.java @@ -0,0 +1,19 @@ +package sh.adelessfox.odradek.ui.components.properties; + +import javax.swing.*; +import java.util.function.Consumer; +import java.util.function.Supplier; + +record BooleanProperty( + String label, + Supplier getter, + Consumer setter +) implements Property { + @Override + public JComponent create() { + var input = new JCheckBox(label); + input.setSelected(getter.get()); + input.addActionListener(_ -> setter.accept(input.isSelected())); + return input; + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/FloatProperty.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/FloatProperty.java new file mode 100644 index 00000000..50f9ef9c --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/FloatProperty.java @@ -0,0 +1,22 @@ +package sh.adelessfox.odradek.ui.components.properties; + +import javax.swing.*; +import java.util.function.Consumer; +import java.util.function.Supplier; + +record FloatProperty( + String label, + Supplier getter, + Consumer setter, + float min, + float max, + float step +) implements Property.Labeled { + @Override + public JComponent create() { + var model = new SpinnerNumberModel(Math.clamp(getter.get(), min, max), min, max, step); + var input = new JSpinner(model); + input.addChangeListener(_ -> setter.accept(model.getNumber().floatValue())); + return input; + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/Property.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/Property.java new file mode 100644 index 00000000..93fce1a9 --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/Property.java @@ -0,0 +1,11 @@ +package sh.adelessfox.odradek.ui.components.properties; + +import javax.swing.*; + +sealed interface Property permits Property.Labeled, BooleanProperty { + JComponent create(); + + sealed interface Labeled extends Property permits FloatProperty { + String label(); + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyCategoryBuilder.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyCategoryBuilder.java new file mode 100644 index 00000000..a28fa314 --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyCategoryBuilder.java @@ -0,0 +1,36 @@ +package sh.adelessfox.odradek.ui.components.properties; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class PropertyCategoryBuilder { + final String label; + final List properties = new ArrayList<>(); + + PropertyCategoryBuilder(String label) { + this.label = label; + } + + public PropertyCategoryBuilder property( + String label, + Supplier getter, + Consumer setter, + float min, + float max, + float step + ) { + properties.add(new FloatProperty(label, getter, setter, min, max, step)); + return this; + } + + public PropertyCategoryBuilder property( + String label, + Supplier getter, + Consumer setter + ) { + properties.add(new BooleanProperty(label, getter, setter)); + return this; + } +} diff --git a/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyPanelBuilder.java b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyPanelBuilder.java new file mode 100644 index 00000000..3823603e --- /dev/null +++ b/odradek-ui/src/main/java/sh/adelessfox/odradek/ui/components/properties/PropertyPanelBuilder.java @@ -0,0 +1,54 @@ +package sh.adelessfox.odradek.ui.components.properties; + +import net.miginfocom.swing.MigLayout; +import sh.adelessfox.odradek.ui.components.LabeledSeparator; + +import javax.swing.*; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public final class PropertyPanelBuilder { + private final List categories = new ArrayList<>(); + + private PropertyPanelBuilder() { + } + + public static JComponent build(Consumer handler) { + var builder = new PropertyPanelBuilder(); + handler.accept(builder); + return builder.build(); + } + + public PropertyPanelBuilder category(String label, Consumer handler) { + var category = new PropertyCategoryBuilder(label); + handler.accept(category); + categories.add(category); + return this; + } + + public JComponent build() { + var panel = new JPanel(); + panel.setLayout(new MigLayout("ins panel,wrap", "[fill,grow][]")); + panel.setOpaque(false); + + for (var category : categories) { + if (category.properties.isEmpty()) { + throw new IllegalStateException("Category " + category.label + " has no properties"); + } + + panel.add(new LabeledSeparator(category.label), "span,growx"); + + for (var property : category.properties) { + if (property instanceof Property.Labeled labeled) { + panel.add(property.create(), "gap ind"); + panel.add(new JLabel(labeled.label())); + } else { + panel.add(property.create(), "gap ind,span"); + } + } + } + + return panel; + } +} diff --git a/odradek-ui/src/main/resources/themes/FlatLaf.properties b/odradek-ui/src/main/resources/themes/FlatLaf.properties index 034d0f85..62b720ff 100644 --- a/odradek-ui/src/main/resources/themes/FlatLaf.properties +++ b/odradek-ui/src/main/resources/themes/FlatLaf.properties @@ -2,6 +2,8 @@ FlatLaf.experimental.tree.widePathForLocation = true # UI Delegates ToolTipUI = sh.adelessfox.odradek.ui.components.laf.FlatOutlineToolTipUI +SplitButtonUI = sh.adelessfox.odradek.ui.components.laf.FlatSplitButtonUI +LabeledSeparatorUI = sh.adelessfox.odradek.ui.components.laf.FlatLabeledSeparatorUI TitlePane.useWindowDecorations = true TitlePane.unifiedBackground = true @@ -23,6 +25,12 @@ Component.error.background = lighten($Component.error.borderColor, 4%) Component.warning.borderColor = #fedd77 Component.warning.background = lighten($Component.warning.borderColor, 15%) +SplitButton.arrowColor = @buttonArrowColor +SplitButton.arrowWidth = 7 +SplitButton.arrowGap = 4 + +LabeledSeparator.labelForeground = @foreground + ToolPanelButton.size = {scaledDimension}24,24 ToolPanelButton.arc = {scaledInteger}10 ToolPanelButton.background = $Panel.background diff --git a/odradek-viewer-model/src/main/java/module-info.java b/odradek-viewer-model/src/main/java/module-info.java index 060fd83c..f1e51901 100644 --- a/odradek-viewer-model/src/main/java/module-info.java +++ b/odradek-viewer-model/src/main/java/module-info.java @@ -1,12 +1,14 @@ module odradek.viewer.model { + requires com.formdev.flatlaf; requires com.google.gson; + requires com.miglayout.swing; requires java.desktop; requires odradek.core; + requires odradek.game; requires odradek.opengl.awt; requires odradek.opengl; requires odradek.ui; requires org.slf4j; - requires odradek.game; opens sh.adelessfox.odradek.viewer.model.viewport.renderpass to com.google.gson; diff --git a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/SceneViewer.java b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/SceneViewer.java index f38c8bd8..b36e9997 100644 --- a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/SceneViewer.java +++ b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/SceneViewer.java @@ -3,6 +3,9 @@ import sh.adelessfox.odradek.game.Game; import sh.adelessfox.odradek.scene.Scene; import sh.adelessfox.odradek.ui.Viewer; +import sh.adelessfox.odradek.ui.components.SplitButton; +import sh.adelessfox.odradek.ui.components.properties.PropertyPanelBuilder; +import sh.adelessfox.odradek.ui.util.Fugue; import sh.adelessfox.odradek.viewer.model.viewport.Camera; import sh.adelessfox.odradek.viewer.model.viewport.Viewport; import sh.adelessfox.odradek.viewer.model.viewport.ViewportContext; @@ -13,8 +16,11 @@ import wtf.reversed.toolbox.math.Vector3; import javax.swing.*; +import javax.swing.event.PopupMenuEvent; +import javax.swing.event.PopupMenuListener; import java.awt.*; import java.util.Optional; +import java.util.function.Consumer; public record SceneViewer(Scene scene) implements Viewer { public static final class Provider implements Viewer.Provider { @@ -40,7 +46,7 @@ public JComponent createComponent() { .map(Bounds::center) .orElse(Vector3.ZERO); - Camera camera = new Camera(30.f, 0.01f, 1000.f); + Camera camera = new Camera(90.f, 0.01f, 1000.f); camera.position(center.subtract(new Vector3(1.0f, -1.0f, -1.0f))); ViewportContext context = new ViewportContext(); @@ -48,6 +54,7 @@ public JComponent createComponent() { context.setShowVertexColors(true); Viewport viewport = new Viewport(context); + viewport.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, UIManager.getColor("Component.borderColor"))); viewport.setMinimumSize(new Dimension(100, 100)); viewport.addRenderPass(new RenderMeshesPass()); viewport.addRenderPass(new GridRenderPass()); @@ -56,6 +63,76 @@ public JComponent createComponent() { viewport.setCameraOrigin(center); viewport.setScene(scene); - return viewport; + var toolBar = createToolBar(camera, context); + + var panel = new JPanel(); + panel.setLayout(new BorderLayout()); + panel.add(toolBar, BorderLayout.NORTH); + panel.add(viewport, BorderLayout.CENTER); + + return panel; + } + + private JToolBar createToolBar(Camera camera, ViewportContext context) { + var toolBar = new JToolBar(); + toolBar.add(createPopupButton("Camera", Fugue.getIcon("camera"), menu -> fillCameraPopupMenu(menu, camera, context))); + toolBar.add(createPopupButton("Scene", Fugue.getIcon("tree"), menu -> fillScenePopupMenu(menu, context))); + return toolBar; + } + + private JComponent createPopupButton(String text, Icon icon, Consumer filler) { + var button = new SplitButton(); + button.setText(text); + button.setIcon(icon); + button.getPopupMenu().addPopupMenuListener(new PopupMenuListener() { + @Override + public void popupMenuWillBecomeVisible(PopupMenuEvent e) { + filler.accept(button.getPopupMenu()); + } + + @Override + public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { + button.getPopupMenu().removeAll(); + } + + @Override + public void popupMenuCanceled(PopupMenuEvent e) { + button.getPopupMenu().removeAll(); + } + }); + return button; } + + private void fillCameraPopupMenu(JPopupMenu menu, Camera camera, ViewportContext context) { + // @formatter:off + var panel = PropertyPanelBuilder.build(pb -> pb + .category("View", vb -> vb + .property("Near Clip", camera::nearClip, camera::nearClip, 0.01f, 10.f, 0.01f) + .property("Far Clip", camera::farClip, camera::farClip, 10.f, 5000.f, 1.0f) + .property("Field of View", camera::fov, camera::fov, 10.f, 120.f, 1.0f)) + .category("Controls", vb -> vb + .property("Mouse Sensitivity", context::getCameraMouseSensitivity, context::setCameraMouseSensitivity, 0.1f, 10.0f, 0.1f) + .property("Fly Speed", context::getCameraSpeed, context::setCameraSpeed, 1.0f, 100.0f, 1.0f) + .property("Fly Speed (Shift multiplier)", context::getCameraShiftMultiplier, context::setCameraShiftMultiplier, 0.1f, 10.0f, 0.1f) + .property("Fly Speed (Ctrl multiplier)", context::getCameraCtrlMultiplier, context::setCameraCtrlMultiplier, 0.1f, 10.0f, 0.1f))); + // @formatter:on + + menu.add(panel); + } + + private void fillScenePopupMenu(JPopupMenu menu, ViewportContext context) { + // @formatter:off + var panel = PropertyPanelBuilder.build(pb -> pb + .category("Rendering", vb -> vb + .property("Show wireframe", context::isShowWireframe, context::setShowWireframe) + .property("Show vertex UVs", context::isShowVertexUVs, context::setShowVertexUVs) + .property("Show vertex colors", context::isShowVertexColors, context::setShowVertexColors) + .property("Show bounds", context::isShowBounds, context::setShowBounds) + .property("Show skins", context::isShowSkins, context::setShowSkins))); + + // @formatter:on + + menu.add(panel); + } + } diff --git a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Camera.java b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Camera.java index 1e7c4f67..e87572d7 100644 --- a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Camera.java +++ b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Camera.java @@ -1,37 +1,37 @@ package sh.adelessfox.odradek.viewer.model.viewport; +import wtf.reversed.toolbox.math.FloatMath; import wtf.reversed.toolbox.math.Matrix4; +import wtf.reversed.toolbox.math.Vector2; import wtf.reversed.toolbox.math.Vector3; public final class Camera { - private static final float PITCH_LIMIT = (float) Math.PI / 2 - 0.01f; - private static final Vector3 UP = new Vector3(0.f, 0.f, -1.f); + private static final float PITCH_LIMIT = FloatMath.PI_2 - 0.01f; - private int width, height; - private float x, y, z; + private Vector3 position; + private Vector2 viewport; private float fov; - private float near, far; + private float nearClip, farClip; private float yaw, pitch; - public Camera(float fov, float near, float far) { + public Camera(float fov, float nearClip, float farClip) { this.fov = fov; - this.near = near; - this.far = far; + this.nearClip = nearClip; + this.farClip = farClip; } public void rotate(float deltaX, float deltaY) { - yaw -= (float) (Math.PI * deltaX / width); - pitch -= (float) (Math.PI * deltaY / height); + yaw -= (float) (Math.PI * deltaX / viewport.x()); + pitch -= (float) (Math.PI * deltaY / viewport.y()); pitch = Math.clamp(pitch, -PITCH_LIMIT, PITCH_LIMIT); } - public void resize(int width, int height) { - this.width = width; - this.height = height; + public void resize(float width, float height) { + this.viewport = new Vector2(width, height); } public void lookAt(Vector3 target) { - Vector3 dir = target.subtract(new Vector3(x, y, z)).normalize(); + Vector3 dir = target.subtract(position).normalize(); yaw = (float) Math.atan2(dir.y(), dir.x()); pitch = (float) Math.asin(dir.z()); pitch = Math.clamp(pitch, -PITCH_LIMIT, PITCH_LIMIT); @@ -42,30 +42,25 @@ public Matrix4 projectionView() { } public Matrix4 projection() { - var aspect = (float) width / height; - return Matrix4.perspective(fov, aspect, near, far); + return Matrix4.perspective((float) Math.toRadians(fov), viewport.x() / viewport.y(), nearClip, farClip); } public Matrix4 view() { var eye = position(); var center = forward().add(eye); - return Matrix4.lookAt(eye, center, UP); + return Matrix4.lookAt(eye, center, Vector3.Z); } public Vector3 position() { - return new Vector3(x, y, z); + return position; } public void position(Vector3 position) { - this.x = position.x(); - this.y = position.y(); - this.z = position.z(); + this.position = position; } public void move(Vector3 delta) { - this.x += delta.x(); - this.y += delta.y(); - this.z += delta.z(); + this.position = position().add(delta); } public Vector3 up() { @@ -86,11 +81,27 @@ public Vector3 right() { return new Vector3(x, y, 0); } - public float near() { - return near; + public float fov() { + return fov; } - public float far() { - return far; + public void fov(float fov) { + this.fov = fov; + } + + public float nearClip() { + return nearClip; + } + + public void nearClip(float near) { + this.nearClip = near; + } + + public float farClip() { + return farClip; + } + + public void farClip(float farClip) { + this.farClip = farClip; } } diff --git a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Viewport.java b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Viewport.java index 4c8703f5..8cc4c260 100644 --- a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Viewport.java +++ b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/Viewport.java @@ -34,7 +34,6 @@ public final class Viewport extends JComponent implements GLEventListener { private final ViewportAnimator animator; private final ViewportContext context; - private float cameraSpeed = 5f; private float cameraDistance = 1f; private boolean initialized; private Instant lastUpdateTime; @@ -216,12 +215,12 @@ private void updateCamera(float dt) { camera.resize(getFramebufferWidth(), getFramebufferHeight()); context.setShowCameraOrigin(false); - var sensitivity = 1.0f; + var sensitivity = context.getCameraMouseSensitivity(); var mouseDelta = input.mousePositionDelta().multiply(sensitivity); var wheelDelta = input.mouseWheelDelta() * sensitivity * 0.1f; if (input.isMouseDown(MouseEvent.BUTTON1)) { - cameraSpeed = Math.clamp((float) Math.exp(Math.log(cameraSpeed) + wheelDelta), 0.1f, 100.0f); + context.setCameraSpeed(Math.clamp((float) Math.exp(Math.log(context.getCameraSpeed()) + wheelDelta), 0.1f, 100.0f)); updateFlyCamera(dt, mouseDelta); } else if (input.isMouseDown(MouseEvent.BUTTON2)) { updateCameraZoom(Math.clamp((float) Math.exp(Math.log(cameraDistance) - wheelDelta), 0.1f, 100.0f)); @@ -243,12 +242,12 @@ private void updateCameraZoom(float newDistance) { } private void updateFlyCamera(float dt, Vector2 mouse) { - float speed = cameraSpeed * dt; + float speed = context.getCameraSpeed() * dt; if (input.isKeyDown(KeyEvent.VK_SHIFT)) { - speed *= 5.0f; + speed *= context.getCameraShiftMultiplier(); } if (input.isKeyDown(KeyEvent.VK_CONTROL)) { - speed /= 5.0f; + speed *= context.getCameraCtrlMultiplier(); } var position = camera.position(); diff --git a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/ViewportContext.java b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/ViewportContext.java index 27274d17..32002110 100644 --- a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/ViewportContext.java +++ b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/ViewportContext.java @@ -8,6 +8,11 @@ public final class ViewportContext { private boolean showVertexUVs; private boolean showWireframe; + private float cameraMouseSensitivity = 1.0f; + private float cameraSpeed = 5f; + private float cameraShiftMultiplier = 5f; + private float cameraCtrlMultiplier = 0.2f; + public boolean isShowBounds() { return showBounds; } @@ -55,4 +60,36 @@ public boolean isShowWireframe() { public void setShowWireframe(boolean showWireframe) { this.showWireframe = showWireframe; } + + public float getCameraMouseSensitivity() { + return cameraMouseSensitivity; + } + + public void setCameraMouseSensitivity(float cameraMouseSensitivity) { + this.cameraMouseSensitivity = cameraMouseSensitivity; + } + + public float getCameraSpeed() { + return cameraSpeed; + } + + public void setCameraSpeed(float cameraSpeed) { + this.cameraSpeed = cameraSpeed; + } + + public float getCameraShiftMultiplier() { + return cameraShiftMultiplier; + } + + public void setCameraShiftMultiplier(float cameraShiftMultiplier) { + this.cameraShiftMultiplier = cameraShiftMultiplier; + } + + public float getCameraCtrlMultiplier() { + return cameraCtrlMultiplier; + } + + public void setCameraCtrlMultiplier(float cameraCtrlMultiplier) { + this.cameraCtrlMultiplier = cameraCtrlMultiplier; + } } diff --git a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/renderpass/OverlayRenderPass.java b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/renderpass/OverlayRenderPass.java index f0954d6b..5bbd9584 100644 --- a/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/renderpass/OverlayRenderPass.java +++ b/odradek-viewer-model/src/main/java/sh/adelessfox/odradek/viewer/model/viewport/renderpass/OverlayRenderPass.java @@ -1,5 +1,6 @@ package sh.adelessfox.odradek.viewer.model.viewport.renderpass; +import com.formdev.flatlaf.util.UIScale; import sh.adelessfox.odradek.geometry.Mesh; import sh.adelessfox.odradek.geometry.Model; import sh.adelessfox.odradek.scene.Joint; @@ -9,28 +10,16 @@ import sh.adelessfox.odradek.viewer.model.viewport.Camera; import sh.adelessfox.odradek.viewer.model.viewport.Viewport; import sh.adelessfox.odradek.viewer.model.viewport.ViewportContext; -import sh.adelessfox.odradek.viewer.model.viewport.ViewportInput; import wtf.reversed.toolbox.math.Bounds; import wtf.reversed.toolbox.math.Matrix4; import wtf.reversed.toolbox.math.Vector3; -import java.awt.event.KeyEvent; import java.io.IOException; import java.util.*; -import java.util.function.BiConsumer; -import java.util.function.Predicate; public class OverlayRenderPass implements RenderPass { private static final int MAX_JOINTS_TO_DISPLAY_NAMES_FOR = 128; - private static final List toggles = List.of( - new Toggle("Show wireframe", KeyEvent.VK_1, ViewportContext::isShowWireframe, ViewportContext::setShowWireframe), - new Toggle("Show vertex UVs", KeyEvent.VK_2, ViewportContext::isShowVertexUVs, ViewportContext::setShowVertexUVs), - new Toggle("Show vertex colors", KeyEvent.VK_3, ViewportContext::isShowVertexColors, ViewportContext::setShowVertexColors), - new Toggle("Show bounds", KeyEvent.VK_4, ViewportContext::isShowBounds, ViewportContext::setShowBounds), - new Toggle("Show skins", KeyEvent.VK_5, ViewportContext::isShowSkins, ViewportContext::setShowSkins) - ); - private DebugRenderer debug; private Scene scene; private SceneStatistics statistics; @@ -48,15 +37,6 @@ public void dispose() { } } - @Override - public void process(Viewport viewport, ViewportContext context, ViewportInput input, double dt) { - for (Toggle toggle : toggles) { - if (input.isKeyPressed(toggle.keyCode)) { - toggle.toggle(context); - } - } - } - @Override public void draw(Viewport viewport, ViewportContext context, double dt) { var scene = viewport.getScene(); @@ -69,7 +49,7 @@ public void draw(Viewport viewport, ViewportContext context, double dt) { } renderNodes(camera, context); - renderInformation(context, statistics, camera); + renderInformation(statistics, camera); } if (context.isShowCameraOrigin()) { @@ -79,7 +59,7 @@ public void draw(Viewport viewport, ViewportContext context, double dt) { debug.draw(viewport, dt); } - private void renderInformation(ViewportContext context, SceneStatistics statistics, Camera camera) { + private void renderInformation(SceneStatistics statistics, Camera camera) { var position = camera.position(); var text = new StringJoiner("\n"); @@ -94,14 +74,7 @@ private void renderInformation(ViewportContext context, SceneStatistics statisti text.add(" Y % f".formatted(position.y())); text.add(" Z % f".formatted(position.z())); - text.add(""); - text.add("Keybinds:"); - for (int i = 0; i < toggles.size(); i++) { - var toggle = toggles.get(i); - text.add(" [%d] - %s [%c]".formatted(i + 1, toggle.name(), toggle.get().test(context) ? 'X' : ' ')); - } - - debug.billboardText(text.toString(), 10, 10, 1.0f, 1.0f, 1.0f, 10.0f); + debug.billboardText(text.toString(), 10, 10, 1.0f, 1.0f, 1.0f, UIScale.scale(10.0f)); } private void renderNodes(Camera camera, ViewportContext context) { @@ -200,17 +173,6 @@ static SceneStatistics collect(Scene scene) { } } - private record Toggle( - String name, - int keyCode, - Predicate get, - BiConsumer set - ) { - void toggle(ViewportContext context) { - set.accept(context, !get.test(context)); - } - } - private record OverlayNode(Optional skin, List meshes, Matrix4 transform) { }