diff --git a/.gitignore b/.gitignore index 46da8a5..dc42388 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,17 @@ /jsplat-examples/.project /jsplat-examples/.settings /jsplat-examples/.classpath - /jsplat-examples/data/* !/jsplat-examples/data/unitCube-ascii.ply +/jsplat-examples-gltf/target +/jsplat-examples-gltf/.project +/jsplat-examples-gltf/.settings +/jsplat-examples-gltf/.classpath +/jsplat-examples-gltf/data/* + + /jsplat/target /jsplat/.project /jsplat/.settings diff --git a/README.md b/README.md index af53019..fdff8a0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Java libraries for Gaussian splats. - [`jsplat-io-gltf`](./jsplat-io-gltf) - glTF with [`KHR_gaussian_splatting`](https://github.com/KhronosGroup/glTF/pull/2490) - [`jsplat-io-gltf-spz`](./jsplat-io-gltf-spz) - glTF with [`KHR_spz_gaussian_splatting_compression_spz_2`](https://github.com/KhronosGroup/glTF/pull/2531) - [`jsplat-io-sog`](./jsplat-io-sog) - [SOG Format](https://developer.playcanvas.com/user-manual/gaussian-splatting/formats/sog/), with a basic reader and an **experimental** (and slow) writer. +- [`jsplat-processing`](./jsplat-processing) - Experimental library for basic processing operations on splats A basic viewer implementation can be found in [`jsplat-viewer`](./jsplat-viewer), with the actual implementation based on LWJGL in [`jsplat-viewer-lwjgl`](./jsplat-viewer-lwjgl). diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSet.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSet.java new file mode 100644 index 0000000..184b363 --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSet.java @@ -0,0 +1,149 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.Splat; +import de.javagl.jsplat.Splats; +import de.javagl.jsplat.processing.SplatTransforms; + +/** + * Internal representation of a splat data set inside the UI + */ +class DataSet +{ + /** + * A name for the data set, to be displayed in the UI + */ + private final String name; + + /** + * The initial (untransformed) splats + */ + private final List initialSplats; + + /** + * The SH degree of the initial splats + */ + private final int shDegree; + + /** + * The current transform that is applied to the splats + */ + private Transform currentTransform; + + /** + * The current splats, computed from the initial ones using the transform + */ + private final List currentSplats; + + /** + * Default constructor + * + * @param name The name, for display purposes + * @param initialSplats The initial splats + */ + DataSet(String name, List initialSplats) + { + this.name = Objects.requireNonNull(name, "The name may not be null"); + this.initialSplats = Objects.requireNonNull(initialSplats, + "The initialSplats may not be null"); + if (initialSplats.isEmpty()) + { + this.shDegree = 0; + } + else + { + this.shDegree = initialSplats.get(0).getShDegree(); + } + this.currentTransform = new Transform(); + this.currentSplats = Splats.copyList(initialSplats); + } + + /** + * Set the transform that should be applied to the initial splats to obtain + * the transformed splats + * + * @param transform The transform + */ + void setTransform(Transform transform) + { + this.currentTransform = transform; + float[] matrix = Transforms.toMatrix(transform); + int shDimensions = Splats.dimensionsForDegree(shDegree); + Consumer t = + SplatTransforms.createTransform(matrix, shDimensions); + for (int i = 0; i < initialSplats.size(); i++) + { + Splat initialSplat = initialSplats.get(i); + MutableSplat currentSplat = currentSplats.get(i); + Splats.setAny(initialSplat, currentSplat); + t.accept(currentSplat); + } + } + + /** + * Returns the SH degree of the splats + * + * @return The degree + */ + int getShDegree() + { + return shDegree; + } + + /** + * Returns the current transform of this data set + * + * @return The transform + */ + Transform getTransform() + { + return currentTransform; + } + + /** + * Returns the current splats, with the current transform applied to them + * + * @return The splats + */ + List getCurrentSplats() + { + return currentSplats; + } + + @Override + public String toString() + { + return name + " (" + initialSplats.size() + " splats)"; + } + +} \ No newline at end of file diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSetsPanel.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSetsPanel.java new file mode 100644 index 0000000..a075763 --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/DataSetsPanel.java @@ -0,0 +1,176 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.util.AbstractList; +import java.util.List; +import java.util.function.Consumer; + +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; +import javax.swing.event.ListSelectionListener; + +/** + * A panel for maintaining a list of splat data sets + */ +class DataSetsPanel extends JPanel +{ + /** + * Serial UID + */ + private static final long serialVersionUID = -3477336710371239875L; + + /** + * The list showing the data sets + */ + private final JList list; + + /** + * The list model for the data sets + */ + private DefaultListModel listModel; + + /** + * The button to remove the selected data set + */ + private JButton removeSelectedButton; + + /** + * Creates a new instance + * + * @param removalCallback A callback that will receive removed data sets + */ + DataSetsPanel(Consumer removalCallback) + { + super(new BorderLayout()); + listModel = new DefaultListModel(); + list = new JList(listModel); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + JScrollPane scrollPane = new JScrollPane(list); + add(scrollPane, BorderLayout.CENTER); + + JPanel p = new JPanel(new FlowLayout()); + removeSelectedButton = new JButton("Remove"); + removeSelectedButton.setEnabled(false); + removeSelectedButton.addActionListener(e -> + { + DataSet removedDataSet = getSelectedDataSet(); + removeDataSet(removedDataSet); + removalCallback.accept(removedDataSet); + }); + p.add(removeSelectedButton); + add(p, BorderLayout.SOUTH); + } + + /** + * Add a listener to be informed about selection changes in the list + * + * @param listener The listener + */ + void addSelectionListener(ListSelectionListener listener) + { + list.addListSelectionListener(listener); + } + + /** + * Add the given data set + * + * @param dataSet The data set + */ + void addDataSet(DataSet dataSet) + { + listModel.addElement(dataSet); + list.setSelectedIndex(listModel.size() - 1); + removeSelectedButton.setEnabled(true); + } + + /** + * Remove the given data set + * + * @param dataSet The data set + */ + void removeDataSet(DataSet dataSet) + { + int oldIndex = list.getSelectedIndex(); + listModel.removeElement(dataSet); + if (oldIndex == 0) + { + list.setSelectedIndex(0); + } + else + { + list.setSelectedIndex(oldIndex - 1); + } + if (listModel.getSize() == 0) + { + removeSelectedButton.setEnabled(false); + } + } + + /** + * Returns the data set that is currently selected (or null + * if the list is empty) + * + * @return The selected data set + */ + DataSet getSelectedDataSet() + { + DataSet selectedDataSet = list.getSelectedValue(); + return selectedDataSet; + } + + /** + * Returns a list of the data sets in this panel + * + * @return The list + */ + List getDataSets() + { + return new AbstractList() + { + @Override + public DataSet get(int index) + { + return listModel.getElementAt(index); + } + + @Override + public int size() + { + return listModel.getSize(); + } + }; + } + + +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java index 5a27a88..a85522c 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/GlbSaveOptions.java @@ -34,6 +34,8 @@ import javax.swing.JPanel; import javax.swing.JRadioButton; +import de.javagl.jsplat.app.common.ExtensionBasedSaveOptions; + /** * A panel that serves as an accessory for the save file chooser, to select the * compression that should be applied in GLB files diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java index 33420d3..9b7777e 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplication.java @@ -38,6 +38,8 @@ import java.net.URI; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.function.Function; import java.util.logging.Logger; @@ -199,11 +201,6 @@ public void actionPerformed(ActionEvent e) */ private JSplatApplicationPanel applicationPanel; - /** - * The splats that are currently displayed in the application panel - */ - private List currentSplats; - /** * Default constructor */ @@ -213,7 +210,7 @@ public void actionPerformed(ActionEvent e) frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); UriTransferHandler transferHandler = - new UriTransferHandler(uri -> openUriInBackground(uri)); + new UriTransferHandler(uris -> openUrisInBackground(uris)); frame.setTransferHandler(transferHandler); menuBar = new JMenuBar(); @@ -222,8 +219,8 @@ public void actionPerformed(ActionEvent e) openFileChooser = new JFileChooser("."); openFileChooser.setFileFilter(new FileNameExtensionFilter( - "Splat Files (.splat, .ply, .spz, .glb)", "splat", "ply", "spz", - "glb")); + "Splat Files (.splat, .ply, .spz, .sog, .glb)", "splat", "ply", + "spz", "sog", "glb")); saveFileChooser = new JFileChooser("."); @@ -235,9 +232,8 @@ public void actionPerformed(ActionEvent e) saveFileChooser.setAccessory(accessory); saveFileChooser.setFileFilter(new FileNameExtensionFilter( - "Splat Files (.splat, .ply, .spz, .glb)", "splat", "ply", "spz", - "glb")); - saveFileAction.setEnabled(false); + "Splat Files (.splat, .ply, .spz, .sog, .glb)", "splat", "ply", + "spz", "sog", "glb")); applicationPanel = new JSplatApplicationPanel(); frame.getContentPane().add(applicationPanel); @@ -331,7 +327,7 @@ private void openFile() if (returnState == JFileChooser.APPROVE_OPTION) { File file = openFileChooser.getSelectedFile(); - openUriInBackground(file.toURI()); + openUrisInBackground(Collections.singletonList(file.toURI())); } } @@ -339,12 +335,13 @@ private void openFile() * Execute the task of loading the data in a background thread, showing a * modal dialog. * - * @param uri The URI to load from + * @param uris The URIs to load from */ - void openUriInBackground(URI uri) + void openUrisInBackground(List uris) { - logger.info("Loading " + uri); + logger.info("Loading " + uris); + URI uri = uris.get(0); if (UriUtils.isLocalFile(uri)) { URI directory = UriUtils.getParent(uri); @@ -368,19 +365,30 @@ void openUriInBackground(URI uri) throw new UncheckedIOException(e); } }; - UriLoading.loadInBackground(uri, loader, (resultUri, resultSplats) -> - { - currentSplats = resultSplats; - if (currentSplats != null) + UriLoading.loadAllInBackground(uris, loader, + (resultUris, resultSplatLists) -> { - saveFileAction.setEnabled(true); - applicationPanel.setSplats(currentSplats); - } - else - { - saveFileAction.setEnabled(false); - } - }); + processLoadedSplats(resultUris, resultSplatLists); + }); + } + + /** + * Process the given splats that have been loaded from a URI + * + * @param uris The URIs + * @param splatLists The splat lists + */ + private void processLoadedSplats(List uris, + List> splatLists) + { + List names = new ArrayList(); + for (int i = 0; i < uris.size(); i++) + { + URI uri = uris.get(i); + String fileName = Paths.get(uri).getFileName().toString(); + names.add(fileName); + } + applicationPanel.addSplatLists(names, splatLists); } /** @@ -558,8 +566,14 @@ private void saveUnchecked(SplatListWriter w, File file) */ private void save(SplatListWriter w, File file) throws IOException { + List allSplats = applicationPanel.getAllSplats(); + if (allSplats.isEmpty()) + { + logger.severe("No splats are currently loaded"); + return; + } OutputStream outputStream = new FileOutputStream(file); - w.writeList(currentSplats, outputStream); + w.writeList(allSplats, outputStream); outputStream.close(); } diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplicationPanel.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplicationPanel.java index d690be4..c804f0f 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplicationPanel.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/JSplatApplicationPanel.java @@ -27,11 +27,16 @@ package de.javagl.jsplat.app; import java.awt.BorderLayout; -import java.awt.FlowLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.GridLayout; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.function.Consumer; import java.util.function.Supplier; import java.util.logging.Logger; @@ -39,10 +44,17 @@ import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JSpinner; +import javax.swing.JSplitPane; import javax.swing.SpinnerNumberModel; +import javax.swing.event.ChangeListener; +import de.javagl.common.ui.GuiUtils; import de.javagl.common.ui.JSpinners; +import de.javagl.common.ui.JSplitPanes; +import de.javagl.common.ui.panel.collapsible.AccordionPanel; +import de.javagl.jsplat.MutableSplat; import de.javagl.jsplat.Splat; +import de.javagl.jsplat.Splats; import de.javagl.jsplat.viewer.SplatViewer; import de.javagl.jsplat.viewer.SplatViewers; @@ -70,8 +82,8 @@ class JSplatApplicationPanel extends JPanel /** * Whether the camera should be fit to a loaded data set. */ - private boolean doFit = true; - + private boolean doFit; + /** * A label for status messages */ @@ -81,6 +93,16 @@ class JSplatApplicationPanel extends JPanel * The spinner for the FOV */ private JSpinner fovDegYSpinner; + + /** + * The panel containing the list of data sets + */ + private DataSetsPanel dataSetsPanel; + + /** + * The panel for controlling the transforms + */ + private TransformPanel transformPanel; /** * Default constructor @@ -88,18 +110,65 @@ class JSplatApplicationPanel extends JPanel JSplatApplicationPanel() { super(new BorderLayout()); + + JSplitPane mainSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + add(mainSplitPane, BorderLayout.CENTER); + this.splatViewer = SplatViewers.createDefault(); if (this.splatViewer == null) { - add(new JLabel("Could not create SplatViewer instance")); + mainSplitPane.setRightComponent( + new JLabel("Could not create SplatViewer instance")); } else { - add(splatViewer.getRenderComponent(), BorderLayout.CENTER); + JPanel container = new JPanel(new GridLayout(1,1)); + Component renderComponent = splatViewer.getRenderComponent(); + renderComponent.setMinimumSize(new Dimension(10, 10)); + container.add(renderComponent); + mainSplitPane.setRightComponent(container); } - JPanel controlPanel = new JPanel(new FlowLayout()); + JPanel controlPanel = createControlPanel(); + mainSplitPane.setLeftComponent(controlPanel); + JSplitPanes.setDividerLocation(mainSplitPane, 0.3); + + statusLabel = new JLabel(" "); + add(statusLabel, BorderLayout.SOUTH); + + doFit = true; + } + + /** + * Create the control panel + * + * @return The panel + */ + private JPanel createControlPanel() + { + JPanel p = new JPanel(new BorderLayout()); + + AccordionPanel accordionPanel = new AccordionPanel(); + + accordionPanel.addToAccordion("Camera", createCameraPanel()); + accordionPanel.addToAccordion("Dummy data sets", + createDummyDataSetsPanel()); + accordionPanel.addToAccordion("Data sets", createDataSetsPanel()); + accordionPanel.addToAccordion("Transform", createTransformPanel()); + p.add(accordionPanel, BorderLayout.CENTER); + return p; + } + + /** + * Create the camera panel + * + * @return The panel + */ + private JPanel createCameraPanel() + { + JPanel p = new JPanel(new GridLayout(0, 1)); + JButton resetButton = new JButton("Reset camera"); resetButton.addActionListener(e -> { @@ -111,7 +180,7 @@ class JSplatApplicationPanel extends JPanel fovDegYSpinner.setValue(fovDegY); doFit = true; }); - controlPanel.add(resetButton); + p.add(resetButton); JButton fitButton = new JButton("Fit camera"); fitButton.addActionListener(e -> @@ -121,20 +190,12 @@ class JSplatApplicationPanel extends JPanel splatViewer.fitCamera(); } }); - controlPanel.add(fitButton); - - controlPanel.add(createButton("unitCube", UnitCubeSplats::create)); - controlPanel.add(createButton("unitSh", UnitShSplats::create)); - controlPanel.add(createFovDegYSpinnerPanel()); - - add(controlPanel, BorderLayout.NORTH); - - statusLabel = new JLabel(" "); - add(statusLabel, BorderLayout.SOUTH); - - setSplats(null); + p.add(fitButton); + p.add(createFovDegYSpinnerPanel()); + + return p; } - + /** * Create a panel with a spinner for controlling the camera FOV * @@ -143,6 +204,7 @@ class JSplatApplicationPanel extends JPanel private JPanel createFovDegYSpinnerPanel() { JPanel p = new JPanel(new BorderLayout()); + p.add(new JLabel("FOV"), BorderLayout.WEST); SpinnerNumberModel model = new SpinnerNumberModel(60.0, 5.0, 160.0, 1.0); @@ -157,6 +219,7 @@ private JPanel createFovDegYSpinnerPanel() }); JSpinners.setSpinnerDraggingEnabled(fovDegYSpinner, true); p.add(fovDegYSpinner, BorderLayout.CENTER); + return p; } @@ -174,6 +237,21 @@ private float getFovDegY() } + /** + * Create the dummy data sets panel + * + * @return The panel + */ + private JPanel createDummyDataSetsPanel() + { + JPanel p = new JPanel(new GridLayout(0, 1)); + + p.add(createButton("unitCube", UnitCubeSplats::create)); + p.add(createButton("unitSh", UnitShSplats::create)); + + return p; + } + /** * Create a button with the given text to set the splats provided by the * given supplier into the viewer @@ -188,43 +266,154 @@ private JButton createButton(String text, JButton button = new JButton(text); button.addActionListener(e -> { - setSplats(supplier.get()); + addSplats(text, supplier.get()); }); return button; } + + /** + * Create the data sets panel + * + * @return The panel + */ + private JPanel createDataSetsPanel() + { + Consumer removalCallback = (removedDataSet) -> + { + removeDataSet(removedDataSet); + }; + this.dataSetsPanel = new DataSetsPanel(removalCallback); + dataSetsPanel.addSelectionListener((e) -> + { + DataSet selectedDataSet = dataSetsPanel.getSelectedDataSet(); + if (selectedDataSet == null) + { + GuiUtils.setDeepEnabled(transformPanel, false); + return; + } + GuiUtils.setDeepEnabled(transformPanel, true); + Transform transform = selectedDataSet.getTransform(); + transformPanel.setTransform(transform); + }); + return dataSetsPanel; + } + + /** + * Create the transform panel + * + * @return The panel + */ + private JPanel createTransformPanel() + { + this.transformPanel = new TransformPanel(); + GuiUtils.setDeepEnabled(transformPanel, false); + transformPanel.setTransform(new Transform()); + ChangeListener listener = e -> + { + if (splatViewer == null) + { + return; + } + DataSet selectedDataSet = dataSetsPanel.getSelectedDataSet(); + if (selectedDataSet == null) + { + return; + } + Transform transform = transformPanel.getTransform(); + selectedDataSet.setTransform(transform); + splatViewer.updateSplats(); + }; + transformPanel.addChangeListener(listener); + return transformPanel; + } /** - * Set the splats that should be displayed + * Add the splats that should be displayed * - * @param splats The splats + * @param names The name + * @param splatLists The splat lists */ - void setSplats(List splats) + void addSplatLists(List names, + List> splatLists) { if (splatViewer == null) { logger.warning("No SplatViewer was craeted"); return; } - splatViewer.setSplats(splats); - if (splats != null && doFit) + + List> currentSplatLists = + new ArrayList>(); + for (int i = 0; i < names.size(); i++) + { + String name = names.get(i); + List splats = splatLists.get(i); + + DataSet dataSet = new DataSet(name, splats); + dataSetsPanel.addDataSet(dataSet); + + List currentSplats = dataSet.getCurrentSplats(); + currentSplatLists.add(currentSplats); + } + splatViewer.addSplatLists(currentSplatLists); + + if (doFit) { splatViewer.fitCamera(); doFit = false; } + updateStatus(); + } - if (splats == null || splats.size() == 0) + /** + * Add the splats that should be displayed + * + * @param name The name + * @param splats The splats + */ + void addSplats(String name, List splats) + { + addSplatLists(Collections.singletonList(name), + Collections.singletonList(splats)); + } + + /** + * Update the status label + */ + private void updateStatus() + { + List dataSets = dataSetsPanel.getDataSets(); + List> allSplats = + new ArrayList>(); + for (DataSet dataSet : dataSets) { - statusLabel.setText("No splats"); - return; + allSplats.add(dataSet.getCurrentSplats()); } - - int count = splats.size(); - int degree = splats.get(0).getShDegree(); - float minMax[] = computeMinMax(splats); + int count = allSplats.stream().mapToInt(t -> t.size()).sum(); + float minMax[] = computeMinMax(allSplats); String b = boundsToString(minMax); - statusLabel.setText( - count + " splats with degree " + degree + ", bounds: " + b); + statusLabel.setText(count + " splats, bounds: " + b); + } + /** + * Remove the splats of the given data set from the viewer + * + * @param dataSet The data set + */ + private void removeDataSet(DataSet dataSet) + { + if (splatViewer == null) + { + logger.warning("No SplatViewer was craeted"); + return; + } + splatViewer.removeSplats(dataSet.getCurrentSplats()); + List dataSets = dataSetsPanel.getDataSets(); + if (dataSets.isEmpty()) + { + doFit = true; + } + updateStatus(); } /** @@ -251,22 +440,65 @@ private static String boundsToString(float minMax[]) * @param splats The splats * @return The bounding box */ - private static float[] computeMinMax(Iterable splats) + private static float[] computeMinMax( + Iterable> splats) { float minMax[] = new float[] { Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY }; - for (Splat s : splats) + for (Iterable i : splats) { - minMax[0] = Math.min(minMax[0], s.getPositionX()); - minMax[1] = Math.min(minMax[1], s.getPositionY()); - minMax[2] = Math.min(minMax[2], s.getPositionZ()); - minMax[3] = Math.max(minMax[3], s.getPositionX()); - minMax[4] = Math.max(minMax[4], s.getPositionY()); - minMax[5] = Math.max(minMax[5], s.getPositionZ()); + for (Splat s : i) + { + minMax[0] = Math.min(minMax[0], s.getPositionX()); + minMax[1] = Math.min(minMax[1], s.getPositionY()); + minMax[2] = Math.min(minMax[2], s.getPositionZ()); + minMax[3] = Math.max(minMax[3], s.getPositionX()); + minMax[4] = Math.max(minMax[4], s.getPositionY()); + minMax[5] = Math.max(minMax[5], s.getPositionZ()); + } } return minMax; } + /** + * Returns a list of all current splats. + * + * Note: This may be a bit costly and memory-consuming. It is only + * intended for splats that are about to be saved to a file. + * + * @return The list of all splats + */ + List getAllSplats() + { + List dataSets = dataSetsPanel.getDataSets(); + if (dataSets.isEmpty()) + { + return Collections.emptyList(); + } + if (dataSets.size() == 1) + { + DataSet dataSet = dataSets.get(0); + return dataSet.getCurrentSplats(); + } + int maxShDegree = -1; + for (DataSet dataSet : dataSets) + { + maxShDegree = Math.max(maxShDegree, dataSet.getShDegree()); + } + List allSplats = new ArrayList(); + for (DataSet dataSet : dataSets) + { + List splats = dataSet.getCurrentSplats(); + for (MutableSplat splat : splats) + { + MutableSplat newSplat = Splats.create(maxShDegree); + Splats.setAny(splat, newSplat); + allSplats.add(newSplat); + } + } + return allSplats; + } + } diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java index bfb7364..11b5c53 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/PlySaveOptions.java @@ -34,6 +34,7 @@ import javax.swing.JPanel; import javax.swing.JRadioButton; +import de.javagl.jsplat.app.common.ExtensionBasedSaveOptions; import de.javagl.jsplat.io.ply.PlySplatWriter.PlyFormat; /** diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/Transform.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/Transform.java new file mode 100644 index 0000000..068f24c --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/Transform.java @@ -0,0 +1,46 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +/** + * A class representing a transform in a {@link TransformPanel}. + * + * No comments for now... + */ +@SuppressWarnings("javadoc") +class Transform +{ + float translationX = 0.0f; + float translationY = 0.0f; + float translationZ = 0.0f; + float rotationRadX = 0.0f; + float rotationRadY = 0.0f; + float rotationRadZ = 0.0f; + float scaleX = 1.0f; + float scaleY = 1.0f; + float scaleZ = 1.0f; +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/TransformPanel.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/TransformPanel.java new file mode 100644 index 0000000..f24227a --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/TransformPanel.java @@ -0,0 +1,367 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import java.awt.GridBagLayout; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import javax.swing.event.ChangeListener; + +import de.javagl.common.ui.GridBagLayouts; +import de.javagl.common.ui.JSpinners; + +/** + * A panel containing controls for transforms + */ +class TransformPanel extends JPanel +{ + /** + * Serial UID + */ + private static final long serialVersionUID = -177129752930137068L; + + /** + * The spinner for the translation along x + */ + private JSpinner translationXSpinner; + + /** + * The spinner for the translation along y + */ + private JSpinner translationYSpinner; + + /** + * The spinner for the translation along z + */ + private JSpinner translationZSpinner; + + /** + * The spinner for the rotation around x + */ + private JSpinner rotationDegXSpinner; + + /** + * The spinner for the rotation around y + */ + private JSpinner rotationDegYSpinner; + + /** + * The spinner for the rotation around z + */ + private JSpinner rotationDegZSpinner; + + /** + * The spinner for the scale along x + */ + private JSpinner scaleXSpinner; + + /** + * The spinner for the scale along y + */ + private JSpinner scaleYSpinner; + + /** + * The spinner for the scale along z + */ + private JSpinner scaleZSpinner; + + /** + * Default constructor + */ + TransformPanel() + { + super(new GridBagLayout()); + + createTranslationControls(); + createRotationControls(); + createScaleControls(); + } + + /** + * Create the translation controls + */ + private void createTranslationControls() + { + int row = 0; + + SpinnerNumberModel modelX = + new SpinnerNumberModel(0.0, -100000.0, 100000.0, 0.01); + translationXSpinner = new JSpinner(modelX); + JSpinners.setSpinnerDraggingEnabled(translationXSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Translation X:"), + translationXSpinner); + + SpinnerNumberModel modelY = + new SpinnerNumberModel(0.0, -100000.0, 100000.0, 0.01); + translationYSpinner = new JSpinner(modelY); + JSpinners.setSpinnerDraggingEnabled(translationYSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Translation Y:"), + translationYSpinner); + + SpinnerNumberModel modelZ = + new SpinnerNumberModel(0.0, -100000.0, 100000.0, 0.01); + translationZSpinner = new JSpinner(modelZ); + JSpinners.setSpinnerDraggingEnabled(translationZSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Translation Z:"), + translationZSpinner); + } + + /** + * Create the rotation controls + */ + private void createRotationControls() + { + int row = 3; + + SpinnerNumberModel modelX = + new SpinnerNumberModel(0.0, -180.0, 180.0, 0.1); + rotationDegXSpinner = new JSpinner(modelX); + JSpinners.setSpinnerDraggingEnabled(rotationDegXSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Rotation X:"), + rotationDegXSpinner); + + SpinnerNumberModel modelY = + new SpinnerNumberModel(0.0, -180.0, 180.0, 0.1); + rotationDegYSpinner = new JSpinner(modelY); + JSpinners.setSpinnerDraggingEnabled(rotationDegYSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Rotation Y:"), + rotationDegYSpinner); + + SpinnerNumberModel modelZ = + new SpinnerNumberModel(0.0, -180.0, 180.0, 0.1); + rotationDegZSpinner = new JSpinner(modelZ); + JSpinners.setSpinnerDraggingEnabled(rotationDegZSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Rotation Z:"), + rotationDegZSpinner); + } + + /** + * Create the scale controls + */ + private void createScaleControls() + { + int row = 6; + + SpinnerNumberModel modelX = + new SpinnerNumberModel(0.0, -1000.0, 1000.0, 0.001); + scaleXSpinner = new JSpinner(modelX); + JSpinners.setSpinnerDraggingEnabled(scaleXSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Scale X:"), + scaleXSpinner); + + SpinnerNumberModel modelY = + new SpinnerNumberModel(0.0, -1000.0, 1000.0, 0.001); + scaleYSpinner = new JSpinner(modelY); + JSpinners.setSpinnerDraggingEnabled(scaleYSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Scale Y:"), + scaleYSpinner); + + SpinnerNumberModel modelZ = + new SpinnerNumberModel(0.0, -1000.0, 1000.0, 0.001); + scaleZSpinner = new JSpinner(modelZ); + JSpinners.setSpinnerDraggingEnabled(scaleZSpinner, true); + GridBagLayouts.addRow(this, row++, 1, new JLabel("Scale Z:"), + scaleZSpinner); + + } + + /** + * Add the given listener to all relevant UI components + * + * @param e The listener + */ + void addChangeListener(ChangeListener e) + { + rotationDegXSpinner.addChangeListener(e); + rotationDegYSpinner.addChangeListener(e); + rotationDegZSpinner.addChangeListener(e); + + translationXSpinner.addChangeListener(e); + translationYSpinner.addChangeListener(e); + translationZSpinner.addChangeListener(e); + + scaleXSpinner.addChangeListener(e); + scaleYSpinner.addChangeListener(e); + scaleZSpinner.addChangeListener(e); + } + + /** + * Returns the value of the given spinner as a float value + * + * @param spinner The spinner + * @return The value + */ + private static float getFloat(JSpinner spinner) + { + Object value = spinner.getValue(); + Number number = (Number) value; + float f = number.floatValue(); + return f; + } + + /** + * Returns the current rotation angle around x, in radians + * + * @return The angle + */ + private float getRotationRadX() + { + float angleDegX = getFloat(rotationDegXSpinner); + float angleRadX = (float) Math.toRadians(angleDegX); + return angleRadX; + } + + /** + * Returns the current rotation angle around y, in radians + * + * @return The angle + */ + private float getRotationRadY() + { + float angleDegY = getFloat(rotationDegYSpinner); + float angleRadY = (float) Math.toRadians(angleDegY); + return angleRadY; + } + + /** + * Returns the current rotation angle around z, in radians + * + * @return The angle + */ + private float getRotationRadZ() + { + float angleDegZ = getFloat(rotationDegZSpinner); + float angleRadZ = (float) Math.toRadians(angleDegZ); + return angleRadZ; + } + + /** + * Returns the current translation along x + * + * @return The translation + */ + private float getTranslationX() + { + return getFloat(translationXSpinner); + } + + /** + * Returns the current translation along y + * + * @return The translation + */ + private float getTranslationY() + { + return getFloat(translationYSpinner); + } + + /** + * Returns the current translation along z + * + * @return The translation + */ + private float getTranslationZ() + { + return getFloat(translationZSpinner); + } + + /** + * Returns the current scale along x + * + * @return The scale + */ + private float getScaleX() + { + return getFloat(scaleXSpinner); + } + + /** + * Returns the current scale along y + * + * @return The scale + */ + private float getScaleY() + { + return getFloat(scaleYSpinner); + } + + /** + * Returns the current scale along z + * + * @return The scale + */ + private float getScaleZ() + { + return getFloat(scaleZSpinner); + } + + /** + * Returns the current transform + * + * @return The transform + */ + Transform getTransform() + { + Transform t = new Transform(); + + t.translationX = getTranslationX(); + t.translationY = getTranslationY(); + t.translationZ = getTranslationZ(); + + t.rotationRadX = getRotationRadX(); + t.rotationRadY = getRotationRadY(); + t.rotationRadZ = getRotationRadZ(); + + t.scaleX = getScaleX(); + t.scaleY = getScaleY(); + t.scaleZ = getScaleZ(); + return t; + } + + /** + * Set the current transform + * + * @param transform The transform + */ + void setTransform(Transform transform) + { + translationXSpinner.setValue(transform.translationX); + translationYSpinner.setValue(transform.translationY); + translationZSpinner.setValue(transform.translationZ); + + rotationDegXSpinner.setValue(Math.toDegrees(transform.rotationRadX)); + rotationDegYSpinner.setValue(Math.toDegrees(transform.rotationRadY)); + rotationDegZSpinner.setValue(Math.toDegrees(transform.rotationRadZ)); + + scaleXSpinner.setValue(transform.scaleX); + scaleYSpinner.setValue(transform.scaleY); + scaleZSpinner.setValue(transform.scaleZ); + } +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/Transforms.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/Transforms.java new file mode 100644 index 0000000..1cab4cb --- /dev/null +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/Transforms.java @@ -0,0 +1,70 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.app; + +import de.javagl.jsplat.processing.VecMath; + +/** + * Utility methods related to transforms + */ +class Transforms +{ + /** + * Creates a 4x4 matrix (in column major order) for the given transform. + * + * The order of operations is not specified for now. + * + * @param t The transform + * @return The matrix + */ + static float[] toMatrix(Transform t) + { + float matrixX[] = VecMath.rotationX(t.rotationRadX, null); + float matrixY[] = VecMath.rotationY(t.rotationRadY, null); + float matrixZ[] = VecMath.rotationZ(t.rotationRadZ, null); + + float scaleMatrix[] = + VecMath.scale4x4(t.scaleX, t.scaleY, t.scaleZ, null); + + float matrix[] = VecMath.identity4x4(null); + VecMath.mul4x4(matrix, matrixX, matrix); + VecMath.mul4x4(matrix, matrixY, matrix); + VecMath.mul4x4(matrix, matrixZ, matrix); + VecMath.translate4x4(matrix, t.translationX, t.translationY, + t.translationZ, matrix); + VecMath.mul4x4(matrix, scaleMatrix, matrix); + return matrix; + } + + /** + * Private constructor to prevent instantiation + */ + private Transforms() + { + // Private constructor to prevent instantiation + } +} diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/ExtensionBasedSaveOptions.java similarity index 78% rename from jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java rename to jsplat-app/src/main/java/de/javagl/jsplat/app/common/ExtensionBasedSaveOptions.java index bc6727b..54c4ebd 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/ExtensionBasedSaveOptions.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/ExtensionBasedSaveOptions.java @@ -24,12 +24,14 @@ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. */ -package de.javagl.jsplat.app; +package de.javagl.jsplat.app.common; import java.awt.BorderLayout; import java.awt.Component; import java.awt.Container; import java.io.File; +import java.util.ArrayList; +import java.util.List; import javax.swing.BorderFactory; import javax.swing.JFileChooser; @@ -42,10 +44,15 @@ import de.javagl.common.ui.GuiUtils; /** - * A base class for a panel that serves as an accessory for the save file + * A base class for a panel that serves as an accessory for a file * chooser, enabled based on the current file extension. + * + * Subclasses will call the super constructor, and then set up the components + * that they want to have displayed as the accessory. This panel will be + * enabled when the file extension in the file text field has one of the + * specified extensions, and disabled otherwise. */ -class ExtensionBasedSaveOptions extends JPanel +public abstract class ExtensionBasedSaveOptions extends JPanel { /** * Serial UID @@ -61,7 +68,7 @@ class ExtensionBasedSaveOptions extends JPanel * The extension that should cause this panel to become enabled, including * the dot. */ - private String extensionWithDot; + private final List extensionsWithDot; /** * The file name text field, obtained from the file chooser (quirky) @@ -73,14 +80,19 @@ class ExtensionBasedSaveOptions extends JPanel * * @param fileChooser The file chooser * @param title The title - * @param extensionWithDot The file extension, including the dot + * @param extensionsWithDot The file extensions, including the dot, + * checked against the file name case-INsensitely */ - ExtensionBasedSaveOptions(JFileChooser fileChooser, String title, - String extensionWithDot) + protected ExtensionBasedSaveOptions(JFileChooser fileChooser, String title, + String... extensionsWithDot) { super(new BorderLayout()); this.fileChooser = fileChooser; - this.extensionWithDot = extensionWithDot.toLowerCase(); + this.extensionsWithDot = new ArrayList(); + for (String extensionWithDot : extensionsWithDot) + { + this.extensionsWithDot.add(extensionWithDot.toLowerCase()); + } setBorder(BorderFactory.createTitledBorder(title)); @@ -155,8 +167,17 @@ private void updateOptions() GuiUtils.setDeepEnabled(this, false); return; } - boolean isPly = fileName.toLowerCase().endsWith(extensionWithDot); - GuiUtils.setDeepEnabled(this, isPly); + String fileNameLowerCase = fileName.toLowerCase(); + boolean matches = false; + for (String extensionWithDot : extensionsWithDot) + { + if (fileNameLowerCase.endsWith(extensionWithDot)) + { + matches = true; + break; + } + } + GuiUtils.setDeepEnabled(this, matches); } /** diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriLoading.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriLoading.java index b647168..ac69756 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriLoading.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriLoading.java @@ -36,6 +36,9 @@ import java.io.UncheckedIOException; import java.net.URI; import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.function.BiConsumer; @@ -61,49 +64,61 @@ public class UriLoading Logger.getLogger(UriLoading.class.getName()); /** - * Load the data from the given URI in a background thread, and pass the + * Load the data from the given URIs in a background thread, and pass the * result to the given consumer (on the event dispatch thread) * * @param The type of the result * - * @param uri The URI + * @param uris The URI * @param loader The loader * @param consumer The result consumer */ - public static void loadInBackground(URI uri, + public static void loadAllInBackground(List uris, Function loader, - BiConsumer consumer) + BiConsumer, ? super List> consumer) { - logger.fine("Loading " + uri); + logger.fine("Loading " + uris); - SwingTask swingTask = new SwingTask() + SwingTask, Void> swingTask = new SwingTask, Void>() { @Override - protected T doInBackground() throws Exception + protected List doInBackground() throws Exception { - setProgress(-1.0); - try (InputStream inputStream = uri.toURL().openStream()) + List results = new ArrayList(); + for (int i = 0; i < uris.size(); i++) { - return loader.apply(inputStream); + double progress = -1.0; + if (uris.size() > 1) + { + progress = (double) i / (uris.size() - 1); + } + setProgress(progress); + URI uri = uris.get(i); + try (InputStream inputStream = uri.toURL().openStream()) + { + T result = loader.apply(inputStream); + results.add(result); + } } + return results; } }; swingTask.addDoneCallback(finishedTask -> { try { - T result = finishedTask.get(); - consumer.accept(uri, result); + List results = finishedTask.get(); + consumer.accept(uris, results); } catch (CancellationException e) { logger.info( - "Cancelled loading " + uri + " (" + e.getMessage() + ")"); + "Cancelled loading " + uris + " (" + e.getMessage() + ")"); return; } catch (InterruptedException e) { - logger.info("Interrupted while loading " + uri + " (" + logger.info("Interrupted while loading " + uris + " (" + e.getMessage() + ")"); Thread.currentThread().interrupt(); } @@ -132,6 +147,28 @@ protected T doInBackground() throws Exception } + /** + * Load the data from the given URI in a background thread, and pass the + * result to the given consumer (on the event dispatch thread) + * + * @param The type of the result + * + * @param uri The URI + * @param loader The loader + * @param consumer The result consumer + */ + public static void loadInBackground(URI uri, + Function loader, + BiConsumer consumer) + { + BiConsumer, List> c = + (uris, results) -> + { + consumer.accept(uris.get(0), results.get(0)); + }; + loadAllInBackground(Collections.singletonList(uri), loader, c); + } + /** * Load the data from the given URI in a background thread, and pass the * result to the given consumer (on the event dispatch thread) @@ -142,7 +179,13 @@ protected T doInBackground() throws Exception public static void loadInBackground(URI uri, BiConsumer consumer) { - loadInBackground(uri, UriLoading::readAsByteBufferUnchecked, consumer); + BiConsumer, List> c = + (uris, byteBuffer) -> + { + consumer.accept(uris.get(0), byteBuffer.get(0)); + }; + loadAllInBackground(Collections.singletonList(uri), + UriLoading::readAsByteBufferUnchecked, c); } /** diff --git a/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriTransferHandler.java b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriTransferHandler.java index 8836ac3..117be5c 100644 --- a/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriTransferHandler.java +++ b/jsplat-app/src/main/java/de/javagl/jsplat/app/common/UriTransferHandler.java @@ -34,7 +34,9 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.function.Consumer; @@ -42,8 +44,8 @@ import javax.swing.TransferHandler; /** - * Implementation of a transfer handler that allows transferring files or - * links, passing the respective URLs or files as URIs to a URI consumer. + * Implementation of a transfer handler that allows transferring files or links, + * passing the respective URLs or files as URIs to a URI consumer. */ public class UriTransferHandler extends TransferHandler { @@ -61,8 +63,8 @@ public class UriTransferHandler extends TransferHandler DataFlavor flavor = null; try { - flavor = new DataFlavor( - "application/x-java-url; class=java.net.URL"); + flavor = + new DataFlavor("application/x-java-url; class=java.net.URL"); } catch (ClassNotFoundException e) { @@ -74,24 +76,25 @@ public class UriTransferHandler extends TransferHandler /** * The consumer for the URIs */ - private final Consumer uriConsumer; + private final Consumer> urisConsumer; /** * Default constructor * - * @param uriConsumer The consumer for the URIs + * @param urisConsumer The consumer for the URIs */ - public UriTransferHandler(Consumer uriConsumer) + public UriTransferHandler( + Consumer> urisConsumer) { - this.uriConsumer = Objects.requireNonNull( - uriConsumer, "The uriConsumer may not be null"); + this.urisConsumer = Objects.requireNonNull(urisConsumer, + "The urisConsumer may not be null"); } @Override public boolean canImport(TransferHandler.TransferSupport support) { - if (!support.isDataFlavorSupported(DataFlavor.javaFileListFlavor) && - !support.isDataFlavorSupported(URL_FLAVOR)) + if (!support.isDataFlavorSupported(DataFlavor.javaFileListFlavor) + && !support.isDataFlavorSupported(URL_FLAVOR)) { return false; } @@ -129,7 +132,7 @@ public boolean importData(TransferHandler.TransferSupport support) { try { - uriConsumer.accept(url.toURI()); + urisConsumer.accept(Collections.singletonList(url.toURI())); } catch (URISyntaxException e) { @@ -143,18 +146,20 @@ public boolean importData(TransferHandler.TransferSupport support) List fileList = getFileListOptional(transferable); if (fileList != null) { + List uris = new ArrayList(); for (File file : fileList) { - uriConsumer.accept(file.toURI()); + uris.add(file.toURI()); } + urisConsumer.accept(uris); return true; } return false; } /** - * Obtains the transfer data from the given Transferable as a file list, - * or returns null if the data can not be obtained + * Obtains the transfer data from the given Transferable as a file list, or + * returns null if the data can not be obtained * * @param transferable The transferable * @return The file list, or null @@ -166,7 +171,7 @@ private static List getFileListOptional(Transferable transferable) Object transferData = transferable.getTransferData(DataFlavor.javaFileListFlavor); @SuppressWarnings("unchecked") - List fileList = (List)transferData; + List fileList = (List) transferData; return fileList; } catch (UnsupportedFlavorException e) @@ -180,8 +185,8 @@ private static List getFileListOptional(Transferable transferable) } /** - * Obtains the transfer data from the given Transferable as a URL, - * or returns null if the data can not be obtained + * Obtains the transfer data from the given Transferable as a URL, or + * returns null if the data can not be obtained * * @param transferable The transferable * @return The URL, or null @@ -190,9 +195,8 @@ private static URL getUrlOptional(Transferable transferable) { try { - Object transferData = - transferable.getTransferData(URL_FLAVOR); - URL url = (URL)transferData; + Object transferData = transferable.getTransferData(URL_FLAVOR); + URL url = (URL) transferData; return url; } catch (UnsupportedFlavorException e) diff --git a/jsplat-examples-gltf/README.md b/jsplat-examples-gltf/README.md new file mode 100644 index 0000000..84261db --- /dev/null +++ b/jsplat-examples-gltf/README.md @@ -0,0 +1,3 @@ +# jsplat-examples-gltf + +Examples for creating glTF files with splats diff --git a/jsplat-examples-gltf/pom.xml b/jsplat-examples-gltf/pom.xml new file mode 100644 index 0000000..f914629 --- /dev/null +++ b/jsplat-examples-gltf/pom.xml @@ -0,0 +1,56 @@ + + 4.0.0 + + jsplat-examples-gltf + + + de.javagl + jsplat-parent + 0.0.1-SNAPSHOT + + + + + de.javagl + jsplat + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-processing + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-examples + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-ply + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-gltf + 0.0.1-SNAPSHOT + + + de.javagl + jsplat-io-gltf-spz + 0.0.1-SNAPSHOT + + + de.javagl + jgltf-model + 2.0.4 + + + de.javagl + jgltf-model-builder + 2.0.4 + + + \ No newline at end of file diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/CreateSplatGltfExamples.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/CreateSplatGltfExamples.java new file mode 100644 index 0000000..80389c9 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/CreateSplatGltfExamples.java @@ -0,0 +1,285 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; + +import de.javagl.jgltf.model.impl.DefaultMeshPrimitiveModel; +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.SplatListWriter; +import de.javagl.jsplat.examples.UnitCubeSplats; +import de.javagl.jsplat.examples.UnitShSplats; +import de.javagl.jsplat.io.ply.PlySplatWriter; +import de.javagl.jsplat.io.ply.PlySplatWriter.PlyFormat; +import de.javagl.jsplat.io.spz.SpzSplatWriter; + +/** + * Methods to create splat glTF example files + */ +public class CreateSplatGltfExamples +{ + /** + * The entry point of the application + * + * @param args Not used + * @throws IOException When an IO error occurs + */ + public static void main(String[] args) throws IOException + { + createGltfs(); + createCustom(); + } + + /** + * Create the main glTF examples + * + * @throws IOException When an IO error occurs + */ + private static void createGltfs() throws IOException + { + String baseDir = "./data/"; + Files.createDirectories(Paths.get(baseDir)); + + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + createSplatsInMesh(w); + w.write(new FileOutputStream(baseDir + "SplatsInMesh.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + createMeshInSplats(w); + w.write(new FileOutputStream(baseDir + "MeshInSplats.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + createShGrid(w); + w.write(new FileOutputStream(baseDir + "ShGrid.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + w.addSplats(SplatRotationTests.createRotationsX(), null); + w.write(new FileOutputStream(baseDir + "RotationsX.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + w.addSplats(SplatRotationTests.createRotationsY(), null); + w.write(new FileOutputStream(baseDir + "RotationsY.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + w.addSplats(SplatRotationTests.createRotationsZ(), null); + w.write(new FileOutputStream(baseDir + "RotationsZ.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + w.addSplats(SplatScaleTests.createScales(), null); + w.write(new FileOutputStream(baseDir + "Scales.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + createMixedDegrees(w); + w.write(new FileOutputStream(baseDir + "MixedDegrees.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + w.addSplats(SplatDepthTests.createDepthTest(), null); + w.write(new FileOutputStream(baseDir + "Depths.glb")); + } + { + GenericGltfSplatWriter w = new GenericGltfSplatWriter(); + createScaledScales(w); + w.write(new FileOutputStream(baseDir + "ScaledScales.glb")); + } + } + + /** + * Create some highly specific tests + * + * @throws IOException When an IO error occurs + */ + private static void createCustom() throws IOException + { + String baseDir = "./data/"; + Files.createDirectories(Paths.get(baseDir)); + + { + SplatListWriter w = new SpzSplatWriter(); + List splats = SplatRotationTests.createRotationsX(); + w.writeList(splats, + new FileOutputStream(baseDir + "RotationsX.spz")); + } + { + SplatListWriter w = new SpzSplatWriter(); + List splats = SplatRotationTests.createRotationsY(); + w.writeList(splats, + new FileOutputStream(baseDir + "RotationsY.spz")); + } + { + SplatListWriter w = new SpzSplatWriter(); + List splats = SplatRotationTests.createRotationsZ(); + w.writeList(splats, + new FileOutputStream(baseDir + "RotationsZ.spz")); + } + { + SplatListWriter w = new PlySplatWriter(PlyFormat.BINARY_LITTLE_ENDIAN); + List splats = SplatDepthTests.createDepthTest(); + w.writeList(splats, + new FileOutputStream(baseDir + "Depths.ply")); + } + } + + /** + * Fill the given writer with an example splat data set and a mesh that + * fully encloses the splats. + * + * @param w The writer + */ + private static void createSplatsInMesh(GenericGltfSplatWriter w) + { + List splats = UnitCubeSplats.create(); + w.addSplats(splats, null); + + DefaultMeshPrimitiveModel p = GltfModelElements.createUnitCube(); + float[] matrix = Matrices.createMatrixScale(20.0f); + w.addMeshPrimitive(p, matrix); + + } + + /** + * Fill the given writer with an example splat data set and a mesh that is + * contained in the splats. + * + * @param w The writer + */ + private static void createMeshInSplats(GenericGltfSplatWriter w) + { + List splats = UnitCubeSplats.create(); + w.addSplats(splats, null); + + DefaultMeshPrimitiveModel p = GltfModelElements.createUnitCube(); + float[] matrix = Matrices.createMatrixScale(5.0f); + w.addMeshPrimitive(p, matrix); + } + + /** + * Fill the given writer with an example splat data set that consists of 6 + * spherical harmonics test splat mesh primitives. + * + * Each mesh primitive will contain splats that indicate the corners of a + * cube, and contain one splat at the center that looks red from the right, + * cyan from the left, green from the top, magenta from the bottom, blue + * from the front, yellow from the back. + * + * The primitives will be attached to matrices that describe rotations of + * (x:0), (x:90), (x:180), (y:-90), (y:90), and (x:270) degrees, causing all + * different faces to face the viewer. + * + * @param w The writer + */ + private static void createShGrid(GenericGltfSplatWriter w) + { + List splats = UnitShSplats.createDeg3(); + { + float[] matrix = Matrices.createMatrixX(0, -150, -75, 0); + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixX(90, -0, -75, 0); + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixX(180, 150, -75, 0); + w.addSplats(splats, matrix); + } + + { + float[] matrix = Matrices.createMatrixY(-90, -150, 75, 0); + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixY(90, -0, 75, 0); + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixX(270, 150, 75, 0); + w.addSplats(splats, matrix); + } + } + + /** + * Fill the given writer with two splat mesh primitives, the first + * one having SH degree 3, and the second one having SH degree 2. + * + * @param w The writer + */ + private static void createMixedDegrees(GenericGltfSplatWriter w) + { + { + float[] matrix = + Matrices.createMatrixTranslation(-75.0f, 0.0f, 0.0f); + List splats = UnitShSplats.createDeg2(); + w.addSplats(splats, matrix); + } + { + float[] matrix = + Matrices.createMatrixTranslation(75.0f, 0.0f, 0.0f); + List splats = UnitShSplats.createDeg3(); + w.addSplats(splats, matrix); + } + } + + + /** + * TODO + * + * @param w The writer + */ + private static void createScaledScales(GenericGltfSplatWriter w) + { + List splats = SplatScaleTests.createScales(); + { + float[] matrix = Matrices.createMatrixScale(0.5f); + matrix[12] = -200.0f; + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixScale(1.0f); + w.addSplats(splats, matrix); + } + { + float[] matrix = Matrices.createMatrixScale(5.0f); + matrix[12] = 450.0f; + w.addSplats(splats, matrix); + } + } + + +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GenericGltfSplatWriter.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GenericGltfSplatWriter.java new file mode 100644 index 0000000..27625c7 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GenericGltfSplatWriter.java @@ -0,0 +1,286 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; + +import de.javagl.jgltf.model.MeshPrimitiveModel; +import de.javagl.jgltf.model.creation.GltfModelBuilder; +import de.javagl.jgltf.model.impl.DefaultExtensionsModel; +import de.javagl.jgltf.model.impl.DefaultGltfModel; +import de.javagl.jgltf.model.impl.DefaultMeshModel; +import de.javagl.jgltf.model.impl.DefaultMeshPrimitiveModel; +import de.javagl.jgltf.model.impl.DefaultNodeModel; +import de.javagl.jgltf.model.impl.DefaultSceneModel; +import de.javagl.jgltf.model.io.GltfModelWriter; +import de.javagl.jsplat.Splat; +import de.javagl.jsplat.io.gltf.GltfSplatWriter; + +/** + * A class that can write glTF assets with one or more mesh primitives that use + * the KHR_gaussian_splatting extension. + * + * NOTE: This class is preliminary + */ +class GenericGltfSplatWriter +{ + /** + * The extension name (and attribute prefix) + */ + private static final String NAME = "KHR_gaussian_splatting"; + + /** + * The default color space for the extension objects + */ + private static final String DEFAULT_COLOR_SPACE = "BT.709-sRGB"; + + /** + * Key for mesh lookups, consisting of a list of splats and the color space + */ + class MeshKey extends SimpleEntry, String> + { + /** + * Serial UID + */ + private static final long serialVersionUID = 1L; + + /** + * Creates a new instance + * + * @param key The key + * @param value The value + */ + public MeshKey(List key, String value) + { + super(key, value); + } + } + + /** + * An instantiation of a splat primitive mesh + */ + private static class Instantiation + { + /** + * The matrix for the node + */ + private float[] matrix; + } + + /** + * The mapping of keys to the instantiations + */ + private final Map> instantiations; + + /** + * The list of mesh primitives that have been added + */ + private final List meshPrimitiveModels; + + /** + * The list of matrices that are associated with the mesh primitives + */ + private final List meshPrimitiveMatrices; + + /** + * Creates a new instance, using an unspecified default color space + */ + public GenericGltfSplatWriter() + { + instantiations = new LinkedHashMap>(); + meshPrimitiveModels = new ArrayList(); + meshPrimitiveMatrices = new ArrayList(); + } + + /** + * Experimental + * + * @param meshPrimitiveModel Do not use + * @param matrix Do not use + */ + public void addMeshPrimitive(MeshPrimitiveModel meshPrimitiveModel, + float matrix[]) + { + Objects.requireNonNull(meshPrimitiveModel, + "The meshPrimitiveModel may not be null"); + meshPrimitiveModels.add(meshPrimitiveModel); + if (matrix != null) + { + if (matrix.length != 16) + { + throw new IllegalArgumentException( + "Expected matrix to have a length of 16, " + + "but it has a length of " + matrix.length); + } + } + meshPrimitiveMatrices.add(matrix); + } + + /** + * Add the given splats to this instance, using an unspecified default color + * space. + * + * The given splats will be contained in one mesh primitive that is part of + * a mesh that is attached to a node that has the given matrix. + * + * @param splats The splats + * @param matrix An optional matrix + * @throws IllegalArgumentException If the given matrix is not + * null and has a length that is not 16 + */ + public void addSplats(List splats, float matrix[]) + { + addSplats(splats, matrix, DEFAULT_COLOR_SPACE); + } + + /** + * Add the given splats to this instance. + * + * The given splats will be contained in one mesh primitive that is part of + * a mesh that is attached to a node that has the given matrix. + * + * @param splats The splats + * @param matrix An optional matrix + * @param colorSpace The color space for the splat extension + * @throws IllegalArgumentException If the given matrix is not + * null and has a length that is not 16 + */ + public void addSplats(List splats, float matrix[], + String colorSpace) + { + Objects.requireNonNull(splats, "The splats may not be null"); + if (matrix != null) + { + if (matrix.length != 16) + { + throw new IllegalArgumentException( + "Expected matrix to have a length of 16, " + + "but it has a length of " + matrix.length); + } + } + + Instantiation instantiation = new Instantiation(); + instantiation.matrix = matrix == null ? null : matrix.clone(); + + MeshKey meshKey = new MeshKey(splats, colorSpace); + List list = instantiations.computeIfAbsent(meshKey, + (k) -> new ArrayList()); + list.add(instantiation); + } + + /** + * Write the glTF asset, as a binary glTF (GLB) file to the given output + * stream. + * + * @param outputStream The output stream + * @throws IOException If an IO error occurs + */ + public void write(OutputStream outputStream) throws IOException + { + DefaultSceneModel sceneModel = new DefaultSceneModel(); + + // Create a mapping from the mesh keys to the respective mesh + // models, each containing one mesh primitive with the + // splat extension + Map splatMeshModels = + new LinkedHashMap(); + for (MeshKey meshKey : instantiations.keySet()) + { + List splats = meshKey.getKey(); + String colorSpace = meshKey.getValue(); + + DefaultMeshPrimitiveModel meshPrimitiveModel = + GltfSplatWriter.createMeshPrimitiveModel(splats); + + // Manually add the extension (there is no model-level + // representation of this extension yet) + Map extension = + GltfSplatWriter.createExtension(colorSpace); + meshPrimitiveModel.addExtension(NAME, extension); + + DefaultMeshModel meshModel = new DefaultMeshModel(); + meshModel.addMeshPrimitiveModel(meshPrimitiveModel); + + splatMeshModels.put(meshKey, meshModel); + } + + // For each "instantiation", create a node with the corresponding + // matrix, and attach the corresponding splat mesh to that node + for (Entry> entry : instantiations + .entrySet()) + { + MeshKey meshKey = entry.getKey(); + DefaultMeshModel meshModel = splatMeshModels.get(meshKey); + + List instantiationsList = entry.getValue(); + for (Instantiation instantiation : instantiationsList) + { + float matrix[] = instantiation.matrix; + + DefaultNodeModel nodeModel = new DefaultNodeModel(); + nodeModel.setMatrix(matrix); + nodeModel.addMeshModel(meshModel); + sceneModel.addNode(nodeModel); + } + } + + // Add the mesh primitives that have been added manually + for (int i = 0; i < meshPrimitiveModels.size(); i++) + { + MeshPrimitiveModel meshPrimitiveModel = meshPrimitiveModels.get(i); + float matrix[] = meshPrimitiveMatrices.get(i); + + DefaultMeshModel meshModel = new DefaultMeshModel(); + meshModel.addMeshPrimitiveModel(meshPrimitiveModel); + + DefaultNodeModel nodeModel = new DefaultNodeModel(); + nodeModel.setMatrix(matrix); + nodeModel.addMeshModel(meshModel); + sceneModel.addNode(nodeModel); + } + + GltfModelBuilder b = GltfModelBuilder.create(); + b.addSceneModel(sceneModel); + DefaultGltfModel gltfModel = b.build(); + + DefaultExtensionsModel extensionsModel = gltfModel.getExtensionsModel(); + extensionsModel.addExtensionsUsed(Arrays.asList(NAME)); + + GltfModelWriter w = new GltfModelWriter(); + w.writeBinary(gltfModel, outputStream); + } + +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GltfModelElements.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GltfModelElements.java new file mode 100644 index 0000000..605561e --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/GltfModelElements.java @@ -0,0 +1,99 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import java.nio.FloatBuffer; +import java.nio.IntBuffer; + +import de.javagl.jgltf.model.MaterialModel; +import de.javagl.jgltf.model.creation.MaterialBuilder; +import de.javagl.jgltf.model.creation.MeshPrimitiveBuilder; +import de.javagl.jgltf.model.impl.DefaultMeshPrimitiveModel; +import de.javagl.jgltf.model.v2.MaterialModelV2; + +/** + * Methods to create parts of a glTF model + */ +class GltfModelElements +{ + /** + * Creates a mesh primitive model that describes a unit cube + * + * @return The mesh primitive model + */ + static DefaultMeshPrimitiveModel createUnitCube() + { + int indices[] = new int[] + { 0, 2, 1, 0, 3, 2, 4, 6, 5, 4, 7, 6, 8, 10, 9, 8, 11, 10, 12, 14, 13, + 12, 15, 14, 16, 18, 17, 16, 19, 18, 20, 22, 21, 20, 23, 22 }; + float positions[] = new float[] + { 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, + 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, + 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, + 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f }; + float normals[] = new float[] + { 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, + -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, + 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, + 1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, + 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, + 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f, + 0.0f, -1.0f, 0.0f, 0.0f, -1.0f, 0.0f }; + MeshPrimitiveBuilder b = MeshPrimitiveBuilder.create(); + b.setIntIndicesAsShort(IntBuffer.wrap(indices)); + b.addPositions3D(FloatBuffer.wrap(positions)); + b.addNormals3D(FloatBuffer.wrap(normals)); + DefaultMeshPrimitiveModel m = b.build(); + m.setMaterialModel(createMaterial()); + return m; + } + + /** + * Create a simple, two-sides material + * + * @return The material + */ + private static MaterialModel createMaterial() + { + MaterialBuilder b = MaterialBuilder.create(); + b.setDoubleSided(true); + MaterialModelV2 m = b.build(); + return m; + } + + /** + * Private constructor to prevent instantiation + */ + private GltfModelElements() + { + // Private constructor to prevent instantiation + } + +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/Matrices.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/Matrices.java new file mode 100644 index 0000000..54e3c28 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/Matrices.java @@ -0,0 +1,174 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +/** + * Utility methods to create matrices. + * + * All matrices will be 16-element arrays representing 4x4 matrices in + * column-major order. + */ +class Matrices +{ + /** + * Create a matrix describing a rotation around the x-axis, and the given + * translation + * + * @param angleDeg The angle, in degrees + * @param tx The translation along x + * @param ty The translation along y + * @param tz The translation along z + * @return The matrix + */ + static float[] createMatrixX(float angleDeg, float tx, float ty, + float tz) + { + float matrix[] = new float[16]; + + double angleRad = Math.toRadians(angleDeg); + float c = (float) Math.cos(angleRad); + float s = (float) Math.sin(angleRad); + + // Column 0 + matrix[0] = 1.0f; + matrix[1] = 0.0f; + matrix[2] = 0.0f; + matrix[3] = 0.0f; + + // Column 1 + matrix[4] = 0.0f; + matrix[5] = c; + matrix[6] = s; + matrix[7] = 0.0f; + + // Column 2 + matrix[8] = 0.0f; + matrix[9] = -s; + matrix[10] = c; + matrix[11] = 0.0f; + + // Column 3 (Translation) + matrix[12] = tx; + matrix[13] = ty; + matrix[14] = tz; + matrix[15] = 1.0f; + + return matrix; + } + + /** + * Create a matrix describing a rotation around the y-axis, and the given + * translation + * + * @param angleDeg The angle, in degrees + * @param tx The translation along x + * @param ty The translation along y + * @param tz The translation along z + * @return The matrix + */ + static float[] createMatrixY(float angleDeg, float tx, float ty, + float tz) + { + float matrix[] = new float[16]; + + double angleRad = Math.toRadians(angleDeg); + float c = (float) Math.cos(angleRad); + float s = (float) Math.sin(angleRad); + + // Column 0 + matrix[0] = c; + matrix[1] = 0.0f; + matrix[2] = -s; + matrix[3] = 0.0f; + + // Column 1 + matrix[4] = 0.0f; + matrix[5] = 1.0f; + matrix[6] = 0.0f; + matrix[7] = 0.0f; + + // Column 2 + matrix[8] = s; + matrix[9] = 0.0f; + matrix[10] = c; + matrix[11] = 0.0f; + + // Column 3 (Translation) + matrix[12] = tx; + matrix[13] = ty; + matrix[14] = tz; + matrix[15] = 1.0f; + + return matrix; + } + + /** + * Create a matrix describing uniform scaling + * + * @param s The scaling factor + * @return The matrix + */ + static float[] createMatrixScale(float s) + { + float[] m = new float[16]; + m[0] = s; + m[5] = s; + m[10] = s; + m[15] = 1.0f; + return m; + } + + /** + * Create a matrix describing a translation + * + * @param x The translation in x-direction + * @param y The translation in y-direction + * @param z The translation in z-direction + * @return The matrix + */ + static float[] createMatrixTranslation(float x, float y, float z) + { + float[] m = new float[16]; + m[0] = 1.0f; + m[5] = 1.0f; + m[10] = 1.0f; + m[15] = 1.0f; + m[12] = x; + m[13] = y; + m[14] = z; + return m; + } + + /** + * Private constructor to prevent instantiation + */ + private Matrices() + { + // Private constructor to prevent instantiation + } + +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatDepthTests.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatDepthTests.java new file mode 100644 index 0000000..52feb95 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatDepthTests.java @@ -0,0 +1,106 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import java.util.ArrayList; +import java.util.List; + +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.Splats; + +/** + * Methods to create test data set for splat depth sorting + */ +class SplatDepthTests +{ + /** + * The cube size + */ + private static final float size = 100.0f; + + /** + * Create a test data set for splat depth sorting tests. + * + * Some details are unspecified here. + * + * @return The test data + */ + static List createDepthTest() + { + List splats = new ArrayList(); + + int n = 8; + float minX = 0.25f; + float minY = 0.25f; + float minZ = 0.25f; + float maxX = 0.75f; + float maxY = 0.75f; + float maxZ = 0.75f; + for (int i = 1; i < n; i++) + { + float rel = (float) (i - 1) / (n - 2); + MutableSplat s = Splats.create(0); + + float x = minX + rel * (maxX - minX); + float y = minY + rel * (maxY - minY); + float z = minZ + rel * (maxZ - minZ); + s.setPositionX(-0.5f * size + x * size); + s.setPositionY(-0.5f * size + y * size); + s.setPositionZ(-0.5f * size + z * size); + + s.setScaleX(2.5f); + s.setScaleY(2.5f); + s.setScaleZ(0.01f); + s.setRotationX(0.0f); + s.setRotationY(0.0f); + s.setRotationZ(0.0f); + s.setRotationW(1.0f); + s.setOpacity(Splats.alphaToOpacity(1.0f)); + + float r = ((i & 1) == 0 ? 0.0f : 1.0f); + float g = ((i & 2) == 0 ? 0.0f : 1.0f); + float b = ((i & 4) == 0 ? 0.0f : 1.0f); + + s.setShX(0, Splats.colorToDirectCurrent(r)); + s.setShY(0, Splats.colorToDirectCurrent(g)); + s.setShZ(0, Splats.colorToDirectCurrent(b)); + splats.add(s); + } + + splats.addAll(SplatTests.createCorners(0, size)); + return splats; + } + + /** + * Private constructor to prevent instantiation + */ + private SplatDepthTests() + { + // Private constructor to prevent instantiation + } + +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatRotationTests.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatRotationTests.java new file mode 100644 index 0000000..6d04a93 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatRotationTests.java @@ -0,0 +1,202 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import static de.javagl.jsplat.examples.SplatSetters.setColor; +import static de.javagl.jsplat.examples.SplatSetters.setDefaults; +import static de.javagl.jsplat.examples.SplatSetters.setPosition; +import static de.javagl.jsplat.examples.SplatSetters.setRotationAxisAngleRad; +import static de.javagl.jsplat.examples.SplatSetters.setScale; + +import java.util.ArrayList; +import java.util.List; + +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.Splats; + +/** + * A class creating different test data sets + */ +public class SplatRotationTests +{ + /** + * The size of the cube that contains the splats + */ + private static final float cubeSize = 100.0f; + + /** + * The minimum X-coordinate + */ + private static final float minX = -0.5f * cubeSize; + + /** + * The minimum Y-coordinate + */ + private static final float minY = -0.5f * cubeSize; + + /** + * The minimum Z-coordinate + */ + private static final float minZ = -0.5f * cubeSize; + + /** + * The maximum X-coordinate + */ + private static final float maxX = 0.5f * cubeSize; + + /** + * The maximum Y-coordinate + */ + private static final float maxY = 0.5f * cubeSize; + + /** + * The maximum Z-coordinate + */ + private static final float maxZ = 0.5f * cubeSize; + + /** + * Create a list of splats for rotation tests. + * + * The list will contain 10 splats, at positions that are interpolated along + * the x-axis. Each splat will have a scaling factor of 3.0 along the + * z-axis. The rotation of each splat will be interpolated between 0.0 + * degrees and 90.0 degrees around x. The color will be interpolated from + * white to red. + * + * The list will include splats that indicate the corners of a containing + * cube. + * + * @return The splats + */ + static List createRotationsX() + { + int shDegree = 0; + List splats = new ArrayList(); + + int n = 10; + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float x = minX + a * (maxX - minX); + float angleRad = 0.0f + a * (float) (Math.PI / 2.0); + + MutableSplat s0 = Splats.create(shDegree); + setDefaults(s0); + setPosition(s0, x, 0.0f, 0.0f); + setColor(s0, 1.0f, 1.0f - a, 1.0f - a); + setScale(s0, 1.0f, 1.0f, 3.0f); + setRotationAxisAngleRad(s0, 1.0f, 0.0f, 0.0f, angleRad); + splats.add(s0); + } + splats.addAll(SplatTests.createCorners(shDegree, cubeSize)); + return splats; + } + + /** + * Create a list of splats for rotation tests. + * + * The list will contain 10 splats, at positions that are interpolated along + * the y-axis. Each splat will have a scaling factor of 3.0 along the + * x-axis. The rotation of each splat will be interpolated between 0.0 + * degrees and 90.0 degrees around y. The color will be interpolated from + * white to green. + * + * The list will include splats that indicate the corners of a containing + * cube. + * + * @return The splats + */ + static List createRotationsY() + { + int shDegree = 0; + List splats = new ArrayList(); + + int n = 10; + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float y = minY + a * (maxY - minY); + float angleRad = 0.0f + a * (float) (Math.PI / 2.0); + + MutableSplat s0 = Splats.create(shDegree); + setDefaults(s0); + setPosition(s0, 0.0f, y, 0.0f); + setColor(s0, 1.0f - a, 1.0f, 1.0f - a); + setScale(s0, 3.0f, 1.0f, 1.0f); + setRotationAxisAngleRad(s0, 0.0f, 1.0f, 0.0f, angleRad); + splats.add(s0); + } + splats.addAll(SplatTests.createCorners(shDegree, cubeSize)); + return splats; + } + + /** + * Create a list of splats for rotation tests. + * + * The list will contain 10 splats, at positions that are interpolated along + * the z-axis. Each splat will have a scaling factor of 3.0 along the + * y-axis. The rotation of each splat will be interpolated between 0.0 + * degrees and 90.0 degrees around z. The color will be interpolated from + * white to blue. + * + * The list will include splats that indicate the corners of a containing + * cube. + * + * @return The splats + */ + static List createRotationsZ() + { + int shDegree = 0; + List splats = new ArrayList(); + + int n = 10; + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float z = minZ + a * (maxZ - minZ); + float angleRad = 0.0f + a * (float) (Math.PI / 2.0); + + MutableSplat s0 = Splats.create(shDegree); + setDefaults(s0); + setPosition(s0, 0.0f, 0.0f, z); + setColor(s0, 1.0f - a, 1.0f - a, 1.0f); + setScale(s0, 1.0f, 3.0f, 1.0f); + setRotationAxisAngleRad(s0, 0.0f, 0.0f, 1.0f, angleRad); + splats.add(s0); + } + splats.addAll(SplatTests.createCorners(shDegree, cubeSize)); + return splats; + } + + /** + * Private constructor to prevent instantiation + */ + private SplatRotationTests() + { + // Private constructor to prevent instantiation + } +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatScaleTests.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatScaleTests.java new file mode 100644 index 0000000..51f9097 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatScaleTests.java @@ -0,0 +1,194 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import static de.javagl.jsplat.examples.SplatSetters.setColor; +import static de.javagl.jsplat.examples.SplatSetters.setDefaults; +import static de.javagl.jsplat.examples.SplatSetters.setPosition; +import static de.javagl.jsplat.examples.SplatSetters.setScaleLinear; + +import java.util.ArrayList; +import java.util.List; + +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.Splats; + +/** + * A class creating different test data sets + */ +public class SplatScaleTests +{ + /** + * The size of the cube that contains the splats + */ + private static final float cubeSize = 100.0f; + + /** + * The minimum X-coordinate + */ + private static final float minX = -0.5f * cubeSize; + + /** + * The minimum Y-coordinate + */ + private static final float minY = -0.5f * cubeSize; + + /** + * The minimum Z-coordinate + */ + private static final float minZ = -0.5f * cubeSize; + + /** + * The maximum X-coordinate + */ + private static final float maxX = 0.5f * cubeSize; + + /** + * The maximum Y-coordinate + */ + private static final float maxY = 0.5f * cubeSize; + + /** + * The maximum Z-coordinate + */ + private static final float maxZ = 0.5f * cubeSize; + + /** + * Create a list of splats for scaling tests. + * + * The list will contain 10 scaling test splats for each axis. The scaling + * factors will be interpolated between 1.0 and 25.0. There will be a + * small, white splat at the "end" of each scaling test splat. + * + * Note: This does not make sense. Splats are infinitely large. + * + * @return The splats + */ + static List createScales() + { + int shDegree = 0; + List splats = new ArrayList(); + + int n = 10; + float dotScale = 0.5f; + float minScale = 1.0f; + float maxScale = 25.0f; + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float scale = minScale + a * (maxScale - minScale); + float y = minY + a * (maxY - minY); + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, -scale * 2.0f, y, minZ); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setColor(s, 1.0f, 0.0f, 0.0f); + setPosition(s, 0.0f, y, minZ); + setScaleLinear(s, scale, dotScale, dotScale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, scale * 2.0f, y, minZ); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + } + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float scale = minScale + a * (maxScale - minScale); + float x = minX + a * (maxX - minX); + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, x, minY, -scale * 2.0f); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setColor(s, 0.0f, 0.0f, 1.0f); + setPosition(s, x, minY, 0.0f); + setScaleLinear(s, dotScale, dotScale, scale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, x, minY, scale * 2.0f); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + } + for (int i = 0; i < n; i++) + { + float a = (float) i / (n - 1); + float scale = minScale + a * (maxScale - minScale); + float z = minZ + a * (maxZ - minZ); + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, minX, -scale * 2.0f, z); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setColor(s, 0.0f, 1.0f, 0.0f); + setPosition(s, minX, 0.0f, z); + setScaleLinear(s, dotScale, scale, dotScale); + splats.add(s); + } + { + MutableSplat s = Splats.create(shDegree); + setDefaults(s); + setPosition(s, minX, scale * 2.0f, z); + setScaleLinear(s, dotScale, dotScale, dotScale); + splats.add(s); + } + } + return splats; + } + + /** + * Private constructor to prevent instantiation + */ + private SplatScaleTests() + { + // Private constructor to prevent instantiation + } +} diff --git a/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatTests.java b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatTests.java new file mode 100644 index 0000000..583c299 --- /dev/null +++ b/jsplat-examples-gltf/src/main/java/de/javagl/jsplat/examples/gltf/SplatTests.java @@ -0,0 +1,116 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.examples.gltf; + +import java.util.ArrayList; +import java.util.List; + +import de.javagl.jsplat.MutableSplat; +import de.javagl.jsplat.Splats; + +/** + * Methods to create test data for splats + */ +class SplatTests +{ + /** + * Create a list of splats, representing a cube. + * + * Many details are intentionally not specified. + * + * @param size The size of the cube + * @param degree The degree + * @return The splats + */ + static List createCorners(int degree, float size) + { + List splats = new ArrayList(); + for (int c = 0; c < 8; c++) + { + float x = -0.5f + ((c & 1) == 0 ? 0.0f : 1.0f); + float y = -0.5f + ((c & 2) == 0 ? 0.0f : 1.0f); + float z = -0.5f + ((c & 4) == 0 ? 0.0f : 1.0f); + add(splats, degree, size, x, y, z); + } + return splats; + } + + /** + * Add a splat to the given list, with properties derived from the given + * parameters and some constants. Details are not specified. + * + * @param splats The splats + * @param degree The degree + * @param size The size of the cube + * @param npx The normalized x-coordinate + * @param npy The normalized y-coordinate + * @param npz The normalized z-coordinate + */ + private static void add(List splats, int degree, float size, + float npx, float npy, float npz) + { + MutableSplat splat = Splats.create(degree); + + splat.setPositionX(npx * size); + splat.setPositionY(npy * size); + splat.setPositionZ(npz * size); + + splat.setScaleX(1.0f); + splat.setScaleY(1.0f); + splat.setScaleZ(1.0f); + + splat.setRotationX(0.0f); + splat.setRotationY(0.0f); + splat.setRotationZ(0.0f); + splat.setRotationW(1.0f); + + splat.setOpacity(Splats.alphaToOpacity(1.0f)); + + if (npx == -0.5f && npy == -0.5f && npz == -0.5f) + { + splat.setShX(0, Splats.colorToDirectCurrent(0.1f)); + splat.setShY(0, Splats.colorToDirectCurrent(0.1f)); + splat.setShZ(0, Splats.colorToDirectCurrent(0.1f)); + } + else + { + splat.setShX(0, Splats.colorToDirectCurrent(npx + 0.5f)); + splat.setShY(0, Splats.colorToDirectCurrent(npy + 0.5f)); + splat.setShZ(0, Splats.colorToDirectCurrent(npz + 0.5f)); + } + + splats.add(splat); + } + + /** + * Private constructor to prevent instantiation + */ + private SplatTests() + { + // Private constructor to prevent instantiation + } +} diff --git a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatGridBuilder.java b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatGridBuilder.java index f70eb58..8bb4782 100644 --- a/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatGridBuilder.java +++ b/jsplat-examples/src/main/java/de/javagl/jsplat/examples/SplatGridBuilder.java @@ -16,7 +16,7 @@ /** * Utility class for creating splat test data */ -class SplatGridBuilder +public class SplatGridBuilder { /** * Interface for classes that can receive an object and three floating @@ -24,7 +24,7 @@ class SplatGridBuilder * * @param The object type */ - static interface Consumer3D + public static interface Consumer3D { /** * Accept the given object and values @@ -85,7 +85,7 @@ static interface Consumer3D * @param sizeZ The size of the grid in z-direction * @param supplier The supplier for the splat instances */ - SplatGridBuilder(int sizeX, int sizeY, int sizeZ, + public SplatGridBuilder(int sizeX, int sizeY, int sizeZ, Supplier supplier) { if (sizeX < 1 || sizeY < 1 || sizeZ < 1) @@ -146,7 +146,7 @@ void registerZ(BiConsumer consumer) * @param max The maximum * @param consumer The consumer */ - void registerX(float min, float max, + public void registerX(float min, float max, BiConsumer consumer) { Objects.requireNonNull(consumer, "The consumer may not be null"); @@ -165,7 +165,7 @@ void registerX(float min, float max, * @param max The maximum * @param consumer The consumer */ - void registerY(float min, float max, + public void registerY(float min, float max, BiConsumer consumer) { Objects.requireNonNull(consumer, "The consumer may not be null"); @@ -184,7 +184,7 @@ void registerY(float min, float max, * @param max The maximum * @param consumer The consumer */ - void registerZ(float min, float max, + public void registerZ(float min, float max, BiConsumer consumer) { Objects.requireNonNull(consumer, "The consumer may not be null"); @@ -200,7 +200,7 @@ void registerZ(float min, float max, * * @param consumer The consumer */ - void register(Consumer3D consumer) + public void register(Consumer3D consumer) { Objects.requireNonNull(consumer, "The consumer may not be null"); consumers3D.add(consumer); @@ -211,7 +211,7 @@ void register(Consumer3D consumer) * * @return The splats */ - List generate() + public List generate() { List result = new ArrayList(); for (int x = 0; x < sizeX; x++) diff --git a/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java index 04eca7d..9a643c8 100644 --- a/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java +++ b/jsplat-io-gltf-spz/src/main/java/de/javagl/jsplat/io/gltf/spz/GltfSpzSplatWriter.java @@ -224,9 +224,9 @@ private GltfAssetV2 createGltfAsset(int numPoints, int shDegree, Node node = new Node(); // XXX That matrix again, for CesiumJS... - node.setMatrix(new float[] - { 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, - 0.0f, 0.0f, 0.0f, 0.0f, 1.0f }); + //node.setMatrix(new float[] + //{ 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f, 0.0f, + // 0.0f, 0.0f, 0.0f, 0.0f, 1.0f }); node.setMesh(0); gltf.addNodes(node); diff --git a/jsplat-processing/src/main/java/de/javagl/jsplat/processing/SplatTransforms.java b/jsplat-processing/src/main/java/de/javagl/jsplat/processing/SplatTransforms.java index b69e7cc..a3d0a28 100644 --- a/jsplat-processing/src/main/java/de/javagl/jsplat/processing/SplatTransforms.java +++ b/jsplat-processing/src/main/java/de/javagl/jsplat/processing/SplatTransforms.java @@ -42,7 +42,10 @@ public class SplatTransforms * Transform all splats in the given list with the given matrix. * * The matrix is assumed to be a 16-element array representing a 4x4 matrix - * in column-major order + * in column-major order. + * + * The rotation quaternion of the given splats will be normalized in this + * operation. * * @param list The list * @param matrix4 The matrix @@ -83,9 +86,11 @@ public static Consumer createTransform(float matrix4[], SplatShRotator sr = new SplatShRotator(matrix3, dims); SplatScaleScaler ss = new SplatScaleScaler(scales[0], scales[1], scales[2]); + Consumer transform = s -> { sr.rotateSh(s); + normalizeRotationQuaternion(s); rr.rotate(s); pt.transform(s); ss.scale(s); @@ -109,6 +114,30 @@ public static List translateList(List list, return list; } + /** + * Normalize the rotation quaternion of the given splat. + * + * @param s The splat + */ + private static void normalizeRotationQuaternion(MutableSplat s) + { + float rX = s.getRotationX(); + float rY = s.getRotationY(); + float rZ = s.getRotationZ(); + float rW = s.getRotationW(); + float lenSquared = rX * rX + rY * rY + rZ * rZ + rW * rW; + if (Math.abs(1.0f - lenSquared) < 1e-6) + { + return; + } + float len = (float) Math.sqrt(lenSquared); + float invLen = 1.0f / len; + s.setRotationX(rX * invLen); + s.setRotationY(rY * invLen); + s.setRotationZ(rZ * invLen); + s.setRotationW(rW * invLen); + } + /** * Translate the given splat by the given amount * diff --git a/jsplat-processing/src/main/java/de/javagl/jsplat/processing/VecMath.java b/jsplat-processing/src/main/java/de/javagl/jsplat/processing/VecMath.java index ae50ec8..866ab4b 100644 --- a/jsplat-processing/src/main/java/de/javagl/jsplat/processing/VecMath.java +++ b/jsplat-processing/src/main/java/de/javagl/jsplat/processing/VecMath.java @@ -29,9 +29,12 @@ import java.util.Arrays; /** - * Internal vector math utility functions + * Internal vector math utility functions. + * + * Note: This class is currently public, but it is NOT part of the + * public API! */ -class VecMath +public class VecMath { /** * Epsilon for quaternion computations @@ -269,6 +272,34 @@ static float[] multiplyMatrix4WithPoint(float matrix4[], float point[], return r; } + /** + * Multiply the given 4x4 matrix with the given 3D vector. + * + * The matrix is assumed to be a 16-element array representing a 4x4 matrix + * in column-major order + * + * If the given result is null, a new array will be created and + * returned. + * + * @param matrix4 The matrix + * @param vector The 3D point + * @param result The result + * @return The result + */ + static float[] multiplyMatrix4WithVector(float matrix4[], float vector[], + float result[]) + { + float r[] = validate(result, 3); + + float x = vector[0]; + float y = vector[1]; + float z = vector[2]; + r[0] = matrix4[0] * x + matrix4[4] * y + matrix4[8] * z; + r[1] = matrix4[1] * x + matrix4[5] * y + matrix4[9] * z; + r[2] = matrix4[2] * x + matrix4[6] * y + matrix4[10] * z; + return r; + } + /** * Create a scalar-last quaternion that describes a rotation around the * given axis, about the given angle @@ -578,7 +609,7 @@ static float[] createMatrix4FromMatrix3(float matrix3[], float result[]) * @param result The result * @return The result */ - static float[] identity4x4(float result[]) + public static float[] identity4x4(float result[]) { float r[] = validate(result, 16); @@ -618,6 +649,183 @@ public static float[] translate4x4(float m[], float x, float y, float z, return r; } + + /** + * Create a matrix describing a rotation around the x-axis, and the given + * translation + * + * If the given result is null, a new array will be created and + * returned. + * + * @param angleRad The angle, in radians + * @param result The result + * @return The matrix + */ + public static float[] rotationX(float angleRad, float result[]) + { + float matrix[] = validate(result, 16); + + float c = (float) Math.cos(angleRad); + float s = (float) Math.sin(angleRad); + + // Column 0 + matrix[0] = 1.0f; + matrix[1] = 0.0f; + matrix[2] = 0.0f; + matrix[3] = 0.0f; + + // Column 1 + matrix[4] = 0.0f; + matrix[5] = c; + matrix[6] = s; + matrix[7] = 0.0f; + + // Column 2 + matrix[8] = 0.0f; + matrix[9] = -s; + matrix[10] = c; + matrix[11] = 0.0f; + + // Column 3 + matrix[12] = 0.0f; + matrix[13] = 0.0f; + matrix[14] = 0.0f; + matrix[15] = 1.0f; + + return matrix; + } + + /** + * Create a matrix describing a rotation around the y-axis + * + * If the given result is null, a new array will be created and + * returned. + * + * @param angleRad The angle, in radians + * @param result The result + * @return The matrix + */ + public static float[] rotationY(float angleRad, float result[]) + { + float matrix[] = validate(result, 16); + + float c = (float) Math.cos(angleRad); + float s = (float) Math.sin(angleRad); + + // Column 0 + matrix[0] = c; + matrix[1] = 0.0f; + matrix[2] = -s; + matrix[3] = 0.0f; + + // Column 1 + matrix[4] = 0.0f; + matrix[5] = 1.0f; + matrix[6] = 0.0f; + matrix[7] = 0.0f; + + // Column 2 + matrix[8] = s; + matrix[9] = 0.0f; + matrix[10] = c; + matrix[11] = 0.0f; + + // Column 3 + matrix[12] = 0.0f; + matrix[13] = 0.0f; + matrix[14] = 0.0f; + matrix[15] = 1.0f; + + return matrix; + } + + + /** + * Create a matrix describing a rotation around the z-axis + * + * If the given result is null, a new array will be created and + * returned. + * + * @param angleRad The angle, in radians + * @param result The result + * @return The matrix + */ + public static float[] rotationZ(float angleRad, float result[]) + { + float matrix[] = validate(result, 16); + + float c = (float) Math.cos(angleRad); + float s = (float) Math.sin(angleRad); + + // Column 0 + matrix[0] = c; + matrix[1] = s; + matrix[2] = 0.0f; + matrix[3] = 0.0f; + + // Column 1 + matrix[4] = -s; + matrix[5] = c; + matrix[6] = 0.0f; + matrix[7] = 0.0f; + + // Column 2 + matrix[8] = 0.0f; + matrix[9] = 0.0f; + matrix[10] = 1.0f; + matrix[11] = 0.0f; + + // Column 3 + matrix[12] = 0.0f; + matrix[13] = 0.0f; + matrix[14] = 0.0f; + matrix[15] = 1.0f; + + return matrix; + } + + /** + * Create a matrix with a uniform scale. + * + * If the given result is null, a new array will be created and + * returned. + * + * @param s The scaling + * @param result The result + * @return The result + */ + public static float[] scale4x4(float s, float result[]) + { + float[] r = identity4x4(result); + r[0] = s; + r[5] = s; + r[10] = s; + r[15] = 1.0f; + return r; + } + + /** + * Create a matrix with a given scale. + * + * If the given result is null, a new array will be created and + * returned. + * + * @param sx The scaling along x + * @param sy The scaling along y + * @param sz The scaling along z + * @param result The result + * @return The result + */ + public static float[] scale4x4(float sx, float sy, float sz, float result[]) + { + float[] r = identity4x4(result); + r[0] = sx; + r[5] = sy; + r[10] = sz; + r[15] = 1.0f; + return r; + } + /** * Copy the contents of the source array to the given target array. The * length of the shorter array will determine how many elements are copied. diff --git a/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/BasicSplatSorter.java b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/BasicSplatSorter.java index c35363b..ed7dffa 100644 --- a/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/BasicSplatSorter.java +++ b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/BasicSplatSorter.java @@ -94,15 +94,18 @@ public String toString() public void init(List splats) { this.splats = splats; - depthEntries = new DepthEntry[splats.size()]; - for (int i = 0; i < splats.size(); i++) + if (depthEntries == null || depthEntries.length < splats.size()) { - DepthEntry depthEntry = new DepthEntry(); - depthEntry.index = i; - depthEntry.depth = 0.0f; - depthEntries[i] = depthEntry; + depthEntries = new DepthEntry[splats.size()]; + for (int i = 0; i < splats.size(); i++) + { + DepthEntry depthEntry = new DepthEntry(); + depthEntry.index = i; + depthEntry.depth = 0.0f; + depthEntries[i] = depthEntry; + } + indices = new int[splats.size()]; } - indices = new int[splats.size()]; Arrays.fill(previousViewMatrixRow, Float.NaN); } @@ -167,7 +170,7 @@ protected void performSort(float mx, float my, float mz, float mw) depthEntry.index = i; depthEntry.depth = depth; } - Arrays.parallelSort(depthEntries, (e0, e1) -> + Arrays.parallelSort(depthEntries, 0, numSplats, (e0, e1) -> { if (e0.depth < e1.depth) { diff --git a/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/CompoundList.java b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/CompoundList.java new file mode 100644 index 0000000..80f20a5 --- /dev/null +++ b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/CompoundList.java @@ -0,0 +1,117 @@ +/* + * www.javagl.de - JSplat + * + * Copyright 2025 Marco Hutter - http://www.javagl.de + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + */ +package de.javagl.jsplat.viewer.lwjgl; + +import java.util.AbstractList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.RandomAccess; +import java.util.Set; + +/** + * Implementation of a list that provides a view on multiple other lists + * + * @param The element type + */ +class CompoundList extends AbstractList implements RandomAccess +{ + /** + * The delegate lists + */ + private final Set> delegates; + + /** + * Creates a new instance + */ + CompoundList() + { + this.delegates = new LinkedHashSet>(); + } + + /** + * Add the given delegate list + * + * @param delegate The delegate list + */ + void addDelegate(List delegate) + { + Objects.requireNonNull(delegate, "The delegate may not be null"); + delegates.add(delegate); + } + + /** + * Remove the given delegate list + * + * @param delegate The delegate list + */ + void removeDelegate(List delegate) + { + delegates.remove(delegate); + } + + /** + * Clear the delegate list + */ + void clearDelegates() + { + this.delegates.clear(); + } + + @Override + public T get(int index) + { + if (index < 0) + { + throw new IndexOutOfBoundsException( + "Index may not be negative, but is " + index); + } + int localIndex = index; + for (List delegate : delegates) + { + if (localIndex < delegate.size()) + { + return delegate.get(localIndex); + } + localIndex -= delegate.size(); + } + throw new IndexOutOfBoundsException( + "Index must be smaller than " + size() + ", but is " + index); + } + + @Override + public int size() + { + int size = 0; + for (List delegate : delegates) + { + size += delegate.size(); + } + return size; + } + +} \ No newline at end of file diff --git a/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/SplatViewerLWJGL.java b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/SplatViewerLWJGL.java index 5eb5dc0..4d87020 100644 --- a/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/SplatViewerLWJGL.java +++ b/jsplat-viewer-lwjgl/src/main/java/de/javagl/jsplat/viewer/lwjgl/SplatViewerLWJGL.java @@ -25,6 +25,7 @@ * OTHER DEALINGS IN THE SOFTWARE. */ package de.javagl.jsplat.viewer.lwjgl; + import static org.lwjgl.opengl.GL11.GL_BLEND; import static org.lwjgl.opengl.GL11.GL_COLOR_BUFFER_BIT; import static org.lwjgl.opengl.GL11.GL_CULL_FACE; @@ -96,6 +97,7 @@ import java.nio.IntBuffer; import java.util.List; import java.util.logging.Logger; +import java.util.stream.IntStream; import javax.swing.SwingUtilities; @@ -125,9 +127,9 @@ public class SplatViewerLWJGL extends AbstractSplatViewer implements SplatViewer /** * The logger used in this class */ - private static final Logger logger = + private static final Logger logger = Logger.getLogger(SplatViewerLWJGL.class.getName()); - + /** * A direct float buffer for up to 3 elements */ @@ -223,14 +225,22 @@ public class SplatViewerLWJGL extends AbstractSplatViewer implements SplatViewer /** * The splats that are currently displayed */ - private List splats; + private final CompoundList splats; /** - * The spherical harmonics dimensions AS NEEDED BY THE SHADER, for the - * sh_dim uniform. This means that it is the - * {@link Splat#getShDimensions()} multiplied by 3. + * The maximum SH degree of the currently displayed splats + */ + private int currentMaximumShDegree = -1; + + /** + * The float buffer that will be used for filling the gaussianDataSSBO */ - private int shDim; + private FloatBuffer gaussianData = null; + + /** + * A buffer for the sorted indices, used for filling the gaussianOrderSSBO. + */ + private IntBuffer gaussianOrderData = null; /** * The sorter for the splats, which computes the {@link #gaussianOrderData} @@ -238,10 +248,15 @@ public class SplatViewerLWJGL extends AbstractSplatViewer implements SplatViewer */ private SplatSorter splatSorter; + // ------------------------------------------------------------------------ + // The uniforms for the vertex shader + /** - * A buffer for the sorted indices, used for filling the gaussianOrderSSBO. + * The spherical harmonics dimensions AS NEEDED BY THE SHADER, for the + * sh_dim uniform. This means that it is the + * {@link Splat#getShDimensions()} multiplied by 3. */ - private IntBuffer gaussianOrderData; + private int shDimForShader; /** * The scale modifier, for the scale_modifier uniform @@ -253,6 +268,8 @@ public class SplatViewerLWJGL extends AbstractSplatViewer implements SplatViewer */ private final int renderMode = 4; + // ------------------------------------------------------------------------ + /** * Creates a new instance */ @@ -263,7 +280,7 @@ public class SplatViewerLWJGL extends AbstractSplatViewer implements SplatViewer getRenderComponent().repaint(); }); createCanvas(); - + splats = new CompoundList(); } /** @@ -360,7 +377,7 @@ private void performInitGL() int maxSSBOSize = glGetInteger(GL_MAX_SHADER_STORAGE_BLOCK_SIZE); logger.fine("Maximum SSBO size: " + maxSSBOSize); - + initialized = true; setupView(); } @@ -484,68 +501,133 @@ public void setSplats(List splats) { addPreRenderCommand(() -> { - setSplatsInternal(splats); + this.splats.clearDelegates(); + if (splats != null && !splats.isEmpty()) + { + Splat s0 = splats.get(0); + currentMaximumShDegree = s0.getShDegree(); + this.splats.addDelegate(splats); + } + updateSplatsInternal(); }); } - /** - * Private version of {@link #setSplats(List)}, to be called within a - * pre-render command - * - * @param splats The splats - */ - private void setSplatsInternal(List splats) + @Override + public void addSplats(List splats) { - this.splats = splats; - if (splats == null || splats.isEmpty()) + addPreRenderCommand(() -> { - return; - } - int shDimensions = splats.get(0).getShDimensions(); - this.shDim = shDimensions * 3; - int numSplats = splats.size(); + if (splats != null && !splats.isEmpty()) + { + Splat s0 = splats.get(0); + currentMaximumShDegree = + Math.max(currentMaximumShDegree, s0.getShDegree()); + this.splats.addDelegate(splats); + } + updateSplatsInternal(); + }); + } - // Prepare the buffer that will contain the 'gaussian_data' that - // will be sent to the shader via a Shader Storage Buffer Object - FloatBuffer gaussianData = - BufferUtils.createFloatBuffer(numSplats * (11 + shDim)); - int j = 0; - for (int i = 0; i < numSplats; i++) + @Override + public void addSplatLists(List> splatLists) + { + addPreRenderCommand(() -> { - Splat s = splats.get(i); - gaussianData.put(j++, s.getPositionX()); - gaussianData.put(j++, s.getPositionY()); - gaussianData.put(j++, s.getPositionZ()); + for (List splats : splatLists) + { + if (splats != null && !splats.isEmpty()) + { + Splat s0 = splats.get(0); + currentMaximumShDegree = + Math.max(currentMaximumShDegree, s0.getShDegree()); + this.splats.addDelegate(splats); + } + } + updateSplatsInternal(); + }); + } - gaussianData.put(j++, s.getRotationW()); - gaussianData.put(j++, s.getRotationX()); - gaussianData.put(j++, s.getRotationY()); - gaussianData.put(j++, s.getRotationZ()); + @Override + public void removeSplats(List splats) + { + addPreRenderCommand(() -> + { + this.splats.removeDelegate(splats); + updateSplatsInternal(); + }); + } - gaussianData.put(j++, (float) Math.exp(s.getScaleX())); - gaussianData.put(j++, (float) Math.exp(s.getScaleY())); - gaussianData.put(j++, (float) Math.exp(s.getScaleZ())); + @Override + public void clearSplats() + { + addPreRenderCommand(() -> + { + this.splats.clearDelegates(); + currentMaximumShDegree = -1; + updateSplatsInternal(); + }); + } - gaussianData.put(j++, Splats.opacityToAlpha(s.getOpacity())); + @Override + public void updateSplats() + { + addPreRenderCommand(() -> + { + updateSplatsInternal(); + }); + } - for (int d = 0; d < shDimensions; d++) - { - gaussianData.put(j++, s.getShX(d)); - gaussianData.put(j++, s.getShY(d)); - gaussianData.put(j++, s.getShZ(d)); - } + /** + * Internal version of updateSplats, to be called in a pre-render command + */ + private void updateSplatsInternal() + { + if (splats.isEmpty()) + { + return; } + int numSplats = splats.size(); + ensureSplatsCapacity(numSplats); + updateSplatsData(); + } - // Initialize an fill the SSBO for the Gaussian data - initGaussianDataSSBO(numSplats); - fillGaussianDataSSBO(gaussianData); - - // Initialize the SSBO that will store the Gaussian order data - initGaussianOrderSSBO(numSplats); + /** + * Ensure that the CPU and GPU buffers have a sufficient capacity for the + * given number of splats with the currentMaximumShDegree + * + * @param numSplats The number of splats + */ + private void ensureSplatsCapacity(int numSplats) + { + logger.info("Ensure capacity for " + numSplats + " splats with degree " + + currentMaximumShDegree); - initGaussianOrderData(); + int shDimensions = Splats.dimensionsForDegree(currentMaximumShDegree); + long sizeInFloatsLong = numSplats * (11L + shDimensions * 3L); + long sizeInBytesLong = sizeInFloatsLong * Float.BYTES; + if (sizeInBytesLong > Integer.MAX_VALUE) + { + throw new OutOfMemoryError("Cannot allocate " + sizeInBytesLong + + " bytes in a single buffer"); + } + int sizeInFloats = (int) sizeInFloatsLong; + + if (gaussianData == null || gaussianData.capacity() < sizeInFloats) + { + logger.info("Allocating gaussianData for " + numSplats + " with " + + currentMaximumShDegree); + gaussianData = BufferUtils.createFloatBuffer(sizeInFloats); + initGaussianDataSSBO(numSplats); + } + if (gaussianOrderData == null + || gaussianOrderData.capacity() < numSplats) + { + logger.info("Allocating gaussianOrderData for " + numSplats); + gaussianOrderData = BufferUtils.createIntBuffer(numSplats); + initGaussianOrderSSBO(numSplats); + } } - + /** * Initialize the SSBO for the Gaussian data, for the given number of splats * @@ -560,8 +642,11 @@ private void initGaussianDataSSBO(int numSplats) } gaussianDataSSBO = glCreateBuffers(); + int shDimensions = Splats.dimensionsForDegree(currentMaximumShDegree); + int sizeInFloats = numSplats * (11 + shDimensions * 3); + int sizeInBytes = sizeInFloats * Float.BYTES; + glBindBuffer(GL_SHADER_STORAGE_BUFFER, gaussianDataSSBO); - int sizeInBytes = numSplats * (11 + shDim) * Float.BYTES; glBufferData(GL_SHADER_STORAGE_BUFFER, sizeInBytes, GL_STATIC_DRAW); glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); } @@ -570,15 +655,20 @@ private void initGaussianDataSSBO(int numSplats) * Fill the SSBO for the Gaussian data with the given data * * @param gaussianData The data + * @param numSplats The number of splats */ - private void fillGaussianDataSSBO(FloatBuffer gaussianData) + private void fillGaussianDataSSBO(FloatBuffer gaussianData, int numSplats) { + int shDimensions = Splats.dimensionsForDegree(currentMaximumShDegree); + int sizeInFloats = numSplats * (11 + shDimensions * 3); + int sizeInBytes = sizeInFloats * Float.BYTES; + glBindBuffer(GL_SHADER_STORAGE_BUFFER, gaussianDataSSBO); ByteBuffer br = glMapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, - gaussianData.capacity() * Float.BYTES, - GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); - br.order(ByteOrder.nativeOrder()).asFloatBuffer() - .put(gaussianData.slice()); + sizeInBytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); + FloatBuffer slice = gaussianData.slice(); + slice.limit(sizeInFloats); + br.order(ByteOrder.nativeOrder()).asFloatBuffer().put(slice); glUnmapBuffer(GL_SHADER_STORAGE_BUFFER); glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); } @@ -598,8 +688,9 @@ private void initGaussianOrderSSBO(int numSplats) } gaussianOrderSSBO = glCreateBuffers(); - glBindBuffer(GL_SHADER_STORAGE_BUFFER, gaussianOrderSSBO); int sizeInBytes = numSplats * Integer.BYTES; + + glBindBuffer(GL_SHADER_STORAGE_BUFFER, gaussianOrderSSBO); glBufferData(GL_SHADER_STORAGE_BUFFER, sizeInBytes, GL_STATIC_DRAW); glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); } @@ -608,30 +699,75 @@ private void initGaussianOrderSSBO(int numSplats) * Fill the SSBO for the Gaussian order with the given data * * @param gaussianOrder The data + * @param numSplats The nuber of splats */ - private void fillGaussianOrderSSBO(IntBuffer gaussianOrder) + private void fillGaussianOrderSSBO(IntBuffer gaussianOrder, int numSplats) { + int sizeInBytes = numSplats * Integer.BYTES; + glBindBuffer(GL_SHADER_STORAGE_BUFFER, gaussianOrderSSBO); ByteBuffer br = glMapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, - gaussianOrder.capacity() * Integer.BYTES, - GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); - br.order(ByteOrder.nativeOrder()).asIntBuffer() - .put(gaussianOrder.slice()); + sizeInBytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); + IntBuffer slice = gaussianOrder.slice(); + slice.limit(numSplats); + br.order(ByteOrder.nativeOrder()).asIntBuffer().put(slice); glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT); glUnmapBuffer(GL_SHADER_STORAGE_BUFFER); glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0); } /** - * Initialize the data that is required for sorting the splats by their - * distance from the viewer. + * Copy the data from the current splats into the CPU buffer, and from the + * CPU buffer into the GPU buffer */ - private void initGaussianOrderData() + private void updateSplatsData() { + if (splats.isEmpty()) + { + return; + } + int numSplats = splats.size(); + int shDimensions = Splats.dimensionsForDegree(currentMaximumShDegree); + int stride = (11 + shDimensions * 3); + IntStream.range(0, numSplats).parallel().forEach(i -> + { + int j = i * stride; + Splat s = splats.get(i); + gaussianData.put(j++, s.getPositionX()); + gaussianData.put(j++, s.getPositionY()); + gaussianData.put(j++, s.getPositionZ()); + + gaussianData.put(j++, s.getRotationW()); + gaussianData.put(j++, s.getRotationX()); + gaussianData.put(j++, s.getRotationY()); + gaussianData.put(j++, s.getRotationZ()); + + gaussianData.put(j++, (float) Math.exp(s.getScaleX())); + gaussianData.put(j++, (float) Math.exp(s.getScaleY())); + gaussianData.put(j++, (float) Math.exp(s.getScaleZ())); + + gaussianData.put(j++, Splats.opacityToAlpha(s.getOpacity())); + + for (int d = 0; d < shDimensions; d++) + { + float shX = 0.0f; + float shY = 0.0f; + float shZ = 0.0f; + if (d < s.getShDimensions()) + { + shX = s.getShX(d); + shY = s.getShY(d); + shZ = s.getShZ(d); + } + gaussianData.put(j++, shX); + gaussianData.put(j++, shY); + gaussianData.put(j++, shZ); + } + }); + fillGaussianDataSSBO(gaussianData, numSplats); splatSorter.init(splats); - gaussianOrderData = BufferUtils.createIntBuffer(splats.size()); } - + /** * Update the buffer that stores the indices of the splats, sorted by their * distance to the viewer, based on the current view matrix. @@ -656,18 +792,20 @@ private void performRender() glClearColor(0.0f, 0.0f, 0.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); - if (splats == null || splats.isEmpty()) + if (splats.isEmpty()) { return; } int numSplats = splats.size(); + int shDimensions = Splats.dimensionsForDegree(currentMaximumShDegree); + shDimForShader = shDimensions * 3; glUseProgram(program); // Set the uniforms glUniform1f(scale_modifier_Location, scaleModifier); glUniform1i(render_mod_Location, renderMode); - glUniform1i(sh_dim_Location, shDim); + glUniform1i(sh_dim_Location, shDimForShader); // Set the camera uniforms updateCameraData(); @@ -675,7 +813,7 @@ private void performRender() // Update the 'gaussian_order' data for the shader updateGaussianOrderData(); splatSorter.apply(gaussianOrderData); - fillGaussianOrderSSBO(gaussianOrderData); + fillGaussianOrderSSBO(gaussianOrderData, numSplats); // Bind the required arrays and buffers, and draw the splats glBindVertexArray(vao); diff --git a/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/RenderingCamera.java b/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/RenderingCamera.java index 17db1dc..839598b 100644 --- a/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/RenderingCamera.java +++ b/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/RenderingCamera.java @@ -244,6 +244,7 @@ private void updateView() int h = component.getHeight(); view.setViewport(Rectangles.create(0, 0, w, h)); view.setAspect((float) w / h); + view.setFarClippingPlane(100000.0f); // Workaround for https://github.com/javagl/Rendering/issues/1: // Force an update by modifying the near clipping plane diff --git a/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/SplatViewer.java b/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/SplatViewer.java index 2ae4728..72dac77 100644 --- a/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/SplatViewer.java +++ b/jsplat-viewer/src/main/java/de/javagl/jsplat/viewer/SplatViewer.java @@ -46,6 +46,39 @@ public interface SplatViewer */ void setSplats(List splats); + /** + * Add the splats that should be displayed. + * + * @param splats The splats + */ + void addSplats(List splats); + + /** + * Add the splats that should be displayed. + * + * @param splatLists The splats + */ + void addSplatLists(List> splatLists); + + /** + * Remove the given splats + * + * @param splats The splats + */ + void removeSplats(List splats); + + /** + * Clear the splats that are currently displayed + */ + void clearSplats(); + + /** + * Update the rendering state based on the current splat data, for the + * case that one of the splat lists that have been added did change + * in-place. + */ + void updateSplats(); + /** * Fit the camera to show the current splats. */ diff --git a/jsplat/src/main/java/de/javagl/jsplat/Splats.java b/jsplat/src/main/java/de/javagl/jsplat/Splats.java index 44e00fd..eb09784 100644 --- a/jsplat/src/main/java/de/javagl/jsplat/Splats.java +++ b/jsplat/src/main/java/de/javagl/jsplat/Splats.java @@ -26,6 +26,8 @@ */ package de.javagl.jsplat; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; /** @@ -62,6 +64,23 @@ public static MutableSplat copy(Splat s) return copy; } + /** + * Create a deep copy of the given list of splats + * + * @param splats The splats + * @return The copy + */ + public static List + copyList(Collection splats) + { + List copies = new ArrayList(splats.size()); + for (Splat splat : splats) + { + copies.add(copy(splat)); + } + return copies; + } + /** * Create an unspecified string representation of the given {@link Splat}. *