diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index ab19bb95..7c91be11 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -61,7 +61,7 @@ import edu.jhuapl.trinity.javafx.handlers.SemanticMapEventHandler; import edu.jhuapl.trinity.javafx.handlers.ShapleyEventHandler; import edu.jhuapl.trinity.javafx.javafx3d.Hyperspace3DPane; -import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.Hypersurface3DPane; import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; import edu.jhuapl.trinity.javafx.javafx3d.Projections3DPane; import edu.jhuapl.trinity.javafx.javafx3d.ProjectorPane; @@ -183,8 +183,14 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir setOnSucceeded(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); - setOnFailed(e -> Platform.runLater(() -> - scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); + setOnFailed(e -> { + Throwable failure = getException(); + if (failure != null) { + LOG.error("Trinity asynchronous initialization failed.", failure); + } + Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR))); + }); setOnCancelled(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java b/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java new file mode 100644 index 00000000..6f2a2213 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java @@ -0,0 +1,363 @@ +package edu.jhuapl.trinity.javafx.components; + +import javafx.geometry.VPos; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Label; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; + +/** + * Lightweight Canvas-backed line chart intended for rapidly changing dense vectors. + * + *

Unlike JavaFX {@code LineChart}, this control does not create a scene-graph node + * for every sample. The complete input vector is rendered directly into a Canvas and + * hover inspection is handled by one overlay Canvas plus one reusable Label.

+ * + * @author Sean Phillips + */ +public final class CanvasLineChart extends Pane { + + private static final double LEFT_MARGIN = 54.0; + private static final double RIGHT_MARGIN = 12.0; + private static final double TOP_MARGIN = 12.0; + private static final double BOTTOM_MARGIN = 28.0; + private static final int GRID_DIVISIONS = 4; + + private final Canvas plotCanvas = new Canvas(); + private final Canvas hoverCanvas = new Canvas(); + private final Label hoverLabel = new Label(); + + private double[] values = new double[0]; + private double dataMinimum = Double.NaN; + private double dataMaximum = Double.NaN; + private int hoverIndex = -1; + private String hoverPrefix = "Vector"; + + public CanvasLineChart() { + setMinSize(0.0, 0.0); + setPrefSize(420.0, 150.0); + + plotCanvas.setManaged(false); + hoverCanvas.setManaged(false); + hoverCanvas.setMouseTransparent(false); + + hoverLabel.setManaged(false); + hoverLabel.setMouseTransparent(true); + hoverLabel.setVisible(false); + hoverLabel.setStyle( + "-fx-font-size: 13;" + + "-fx-font-weight: bold;" + + "-fx-text-fill: aliceblue;" + + "-fx-background-color: rgba(20, 24, 30, 0.86);" + + "-fx-background-radius: 4;" + + "-fx-padding: 4 7 4 7;" + ); + + getChildren().addAll(plotCanvas, hoverCanvas, hoverLabel); + + hoverCanvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::handleMouseMoved); + hoverCanvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover()); + } + + public void setHoverPrefix(String hoverPrefix) { + this.hoverPrefix = hoverPrefix == null || hoverPrefix.isBlank() + ? "Vector" + : hoverPrefix; + } + + public void setData(Double[] newData) { + if (newData == null || newData.length == 0) { + values = new double[0]; + dataMinimum = Double.NaN; + dataMaximum = Double.NaN; + clearHover(); + redraw(); + return; + } + + double[] primitive = new double[newData.length]; + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < newData.length; i++) { + Double boxed = newData[i]; + double value = boxed == null ? Double.NaN : boxed; + primitive[i] = value; + if (Double.isFinite(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + + values = primitive; + dataMinimum = min == Double.POSITIVE_INFINITY ? Double.NaN : min; + dataMaximum = max == Double.NEGATIVE_INFINITY ? Double.NaN : max; + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + redraw(); + } + + public void setData(double[] newData) { + if (newData == null || newData.length == 0) { + values = new double[0]; + dataMinimum = Double.NaN; + dataMaximum = Double.NaN; + clearHover(); + redraw(); + return; + } + + values = newData.clone(); + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (double value : values) { + if (Double.isFinite(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + dataMinimum = min == Double.POSITIVE_INFINITY ? Double.NaN : min; + dataMaximum = max == Double.NEGATIVE_INFINITY ? Double.NaN : max; + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + redraw(); + } + + @Override + protected void layoutChildren() { + double width = Math.max(0.0, getWidth()); + double height = Math.max(0.0, getHeight()); + if (plotCanvas.getWidth() != width) plotCanvas.setWidth(width); + if (plotCanvas.getHeight() != height) plotCanvas.setHeight(height); + if (hoverCanvas.getWidth() != width) hoverCanvas.setWidth(width); + if (hoverCanvas.getHeight() != height) hoverCanvas.setHeight(height); + plotCanvas.relocate(0.0, 0.0); + hoverCanvas.relocate(0.0, 0.0); + redraw(); + } + + private void redraw() { + GraphicsContext g = plotCanvas.getGraphicsContext2D(); + double width = plotCanvas.getWidth(); + double height = plotCanvas.getHeight(); + g.clearRect(0.0, 0.0, width, height); + if (width <= LEFT_MARGIN + RIGHT_MARGIN || height <= TOP_MARGIN + BOTTOM_MARGIN) return; + + double plotX = LEFT_MARGIN; + double plotY = TOP_MARGIN; + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + + drawGridAndAxes(g, plotX, plotY, plotWidth, plotHeight); + if (values.length == 0 || !Double.isFinite(dataMinimum) || !Double.isFinite(dataMaximum)) { + drawCenteredMessage(g, width, height, "No data"); + return; + } + + double yMin = dataMinimum; + double yMax = dataMaximum; + if (Double.compare(yMin, yMax) == 0) { + double padding = Math.max(1.0, Math.abs(yMin) * 0.05); + yMin -= padding; + yMax += padding; + } else { + double padding = (yMax - yMin) * 0.05; + yMin -= padding; + yMax += padding; + } + + drawAxisLabels(g, plotX, plotY, plotWidth, plotHeight, yMin, yMax); + drawSeries(g, plotX, plotY, plotWidth, plotHeight, yMin, yMax); + } + + private void drawGridAndAxes(GraphicsContext g, + double x, + double y, + double width, + double height) { + g.setLineWidth(1.0); + g.setStroke(Color.rgb(180, 190, 200, 0.22)); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + double gy = y + height * fraction; + g.strokeLine(x, gy, x + width, gy); + double gx = x + width * fraction; + g.strokeLine(gx, y, gx, y + height); + } + + g.setStroke(Color.rgb(210, 220, 230, 0.72)); + g.strokeLine(x, y, x, y + height); + g.strokeLine(x, y + height, x + width, y + height); + } + + private void drawAxisLabels(GraphicsContext g, + double x, + double y, + double width, + double height, + double yMin, + double yMax) { + g.setFill(Color.rgb(220, 225, 232, 0.90)); + g.setFont(Font.font(11.0)); + g.setTextBaseline(VPos.CENTER); + + g.setTextAlign(TextAlignment.RIGHT); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + double value = yMax - (yMax - yMin) * fraction; + double py = y + height * fraction; + g.fillText(formatValue(value), x - 6.0, py); + } + + g.setTextBaseline(VPos.TOP); + g.setTextAlign(TextAlignment.CENTER); + int lastIndex = Math.max(0, values.length - 1); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + int index = (int) Math.round(lastIndex * fraction); + double px = x + width * fraction; + g.fillText(Integer.toString(index), px, y + height + 6.0); + } + } + + private void drawSeries(GraphicsContext g, + double x, + double y, + double width, + double height, + double yMin, + double yMax) { + double yRange = yMax - yMin; + int lastIndex = Math.max(1, values.length - 1); + + g.setStroke(Color.ALICEBLUE); + g.setLineWidth(1.35); + g.beginPath(); + + boolean pathStarted = false; + for (int i = 0; i < values.length; i++) { + double value = values[i]; + if (!Double.isFinite(value)) { + pathStarted = false; + continue; + } + + double px = x + width * (i / (double) lastIndex); + double py = y + height * ((yMax - value) / yRange); + if (!pathStarted) { + g.moveTo(px, py); + pathStarted = true; + } else { + g.lineTo(px, py); + } + } + g.stroke(); + } + + private void handleMouseMoved(MouseEvent event) { + if (values.length == 0 || !Double.isFinite(dataMinimum) || !Double.isFinite(dataMaximum)) { + clearHover(); + return; + } + + double width = hoverCanvas.getWidth(); + double height = hoverCanvas.getHeight(); + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + if (plotWidth <= 0.0 || plotHeight <= 0.0) return; + + double clampedX = clamp(event.getX(), LEFT_MARGIN, LEFT_MARGIN + plotWidth); + double fraction = (clampedX - LEFT_MARGIN) / plotWidth; + int index = (int) Math.round(fraction * Math.max(0, values.length - 1)); + index = Math.max(0, Math.min(values.length - 1, index)); + double value = values[index]; + if (!Double.isFinite(value)) { + clearHover(); + return; + } + + hoverIndex = index; + drawHover(index, value); + hoverLabel.setText(hoverPrefix + "(" + index + ") = " + formatValue(value)); + hoverLabel.applyCss(); + double labelWidth = hoverLabel.prefWidth(-1.0); + double labelHeight = hoverLabel.prefHeight(-1.0); + double labelX = clamp(event.getX() + 10.0, 0.0, Math.max(0.0, width - labelWidth)); + double labelY = clamp(event.getY() - labelHeight - 10.0, 0.0, Math.max(0.0, height - labelHeight)); + hoverLabel.resizeRelocate(labelX, labelY, labelWidth, labelHeight); + hoverLabel.setVisible(true); + } + + private void drawHover(int index, double value) { + clearHoverCanvas(); + + double width = hoverCanvas.getWidth(); + double height = hoverCanvas.getHeight(); + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + + double yMin = dataMinimum; + double yMax = dataMaximum; + if (Double.compare(yMin, yMax) == 0) { + double padding = Math.max(1.0, Math.abs(yMin) * 0.05); + yMin -= padding; + yMax += padding; + } else { + double padding = (yMax - yMin) * 0.05; + yMin -= padding; + yMax += padding; + } + + int lastIndex = Math.max(1, values.length - 1); + double px = LEFT_MARGIN + plotWidth * (index / (double) lastIndex); + double py = TOP_MARGIN + plotHeight * ((yMax - value) / (yMax - yMin)); + + GraphicsContext g = hoverCanvas.getGraphicsContext2D(); + g.setStroke(Color.rgb(240, 248, 255, 0.50)); + g.setLineWidth(1.0); + g.strokeLine(px, TOP_MARGIN, px, TOP_MARGIN + plotHeight); + g.setFill(Color.ALICEBLUE); + g.fillOval(px - 3.0, py - 3.0, 6.0, 6.0); + } + + private void clearHover() { + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + } + + private void clearHoverCanvas() { + GraphicsContext g = hoverCanvas.getGraphicsContext2D(); + g.clearRect(0.0, 0.0, hoverCanvas.getWidth(), hoverCanvas.getHeight()); + } + + private static void drawCenteredMessage(GraphicsContext g, + double width, + double height, + String message) { + g.setFill(Color.rgb(220, 225, 232, 0.80)); + g.setFont(Font.font(12.0)); + g.setTextAlign(TextAlignment.CENTER); + g.setTextBaseline(VPos.CENTER); + g.fillText(message, width / 2.0, height / 2.0); + } + + private static String formatValue(double value) { + double abs = Math.abs(value); + if ((abs > 0.0 && abs < 0.001) || abs >= 100000.0) { + return String.format("%.3e", value); + } + if (abs >= 1000.0) return String.format("%.1f", value); + if (abs >= 10.0) return String.format("%.2f", value); + return String.format("%.4f", value); + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java index d776e8f1..aebe3090 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java @@ -2,18 +2,14 @@ import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import javafx.geometry.Insets; -import javafx.scene.chart.LineChart; -import javafx.scene.chart.NumberAxis; -import javafx.scene.chart.XYChart; import javafx.scene.control.ChoiceBox; import javafx.scene.control.Label; -import javafx.scene.control.Tooltip; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; @@ -27,34 +23,27 @@ public class FactorControlBox extends VBox { Background transFillBack = new Background(new BackgroundFill( Color.ALICEBLUE.deriveColor(1, 1, 1, 0.222), CornerRadii.EMPTY, Insets.EMPTY)); - public ObservableList> xFactorVector; - public XYChart.Series xFactorSeries; - private LineChart xFactorChart; - - public ObservableList> zFactorVector; - public XYChart.Series zFactorSeries; - private LineChart zFactorChart; + private final CanvasLineChart xFactorChart; + private final CanvasLineChart zFactorChart; public FactorControlBox(double width, double height) { setPrefSize(width, height); - xFactorVector = FXCollections.observableArrayList(); - xFactorSeries = new XYChart.Series("Hyperdimensional Feature Vector", xFactorVector); - this.xFactorChart = new LineChart(new NumberAxis(), new NumberAxis(), FXCollections.observableArrayList(xFactorSeries)); - zFactorVector = FXCollections.observableArrayList(); - zFactorSeries = new XYChart.Series("Dimension Over Time", zFactorVector); - this.zFactorChart = new LineChart(new NumberAxis(), new NumberAxis(), FXCollections.observableArrayList(zFactorSeries)); + xFactorChart = new CanvasLineChart(); + xFactorChart.setHoverPrefix("Factor Vector"); + zFactorChart = new CanvasLineChart(); + zFactorChart.setHoverPrefix("Dimension Over Time"); - xFactorChart.setAnimated(false); - zFactorChart.setAnimated(false); - xFactorChart.setLegendVisible(false); - zFactorChart.setLegendVisible(false); + xFactorChart.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + zFactorChart.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE); + VBox.setVgrow(xFactorChart, Priority.ALWAYS); + VBox.setVgrow(zFactorChart, Priority.ALWAYS); setBackground(Background.EMPTY); Label xFactorLabel = new Label("X Axis (Feature Vector)"); Label zFactorLabel = new Label("Z Axis (Time)"); - ChoiceBox xFactorChoiceBox = new ChoiceBox(FXCollections.observableArrayList( + ChoiceBox xFactorChoiceBox = new ChoiceBox<>(FXCollections.observableArrayList( "Factor-0", "Factor-1", "Factor-2", "Factor-3", "Factor-4", "Factor-5", "Factor-6", "Factor-7")); xFactorChoiceBox.getSelectionModel().select(0); //X0 is the default @@ -64,7 +53,7 @@ public FactorControlBox(double width, double height) { xFactorChoiceBox.getSelectionModel().getSelectedIndex())); }); - ChoiceBox zFactorChoiceBox = new ChoiceBox(FXCollections.observableArrayList( + ChoiceBox zFactorChoiceBox = new ChoiceBox<>(FXCollections.observableArrayList( "Factor-0", "Factor-1", "Factor-2", "Factor-3", "Factor-4", "Factor-5", "Factor-6", "Factor-7")); zFactorChoiceBox.getSelectionModel().select(2); //X2 is the default @@ -83,17 +72,11 @@ public FactorControlBox(double width, double height) { ); } - public void setFactorVector(ObservableList> vector, Double[] newData) { - - vector.clear(); - for (int i = 0; i < newData.length; i++) { - XYChart.Data data = new XYChart.Data(i, newData[i], newData[i]); - Tooltip.install(data.getNode(), new Tooltip("Factor Vector " - + data.getXValue().toString() + "\n" - + data.getYValue())); + public void setXFactorVector(Double[] newData) { + xFactorChart.setData(newData); + } - data.setNode(new HoverNode(i, newData[i])); - vector.add(data); - } + public void setZFactorVector(Double[] newData) { + zFactorChart.setData(newData); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 10c53be0..4b4c5913 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -5,18 +5,20 @@ import edu.jhuapl.trinity.javafx.events.GraphEvent; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; -import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; -import edu.jhuapl.trinity.javafx.javafx3d.SurfaceHeightOrientation; -import edu.jhuapl.trinity.javafx.javafx3d.SurfaceRowOrientation; -import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; -import edu.jhuapl.trinity.javafx.javafx3d.TiledLodManager; -import edu.jhuapl.trinity.javafx.javafx3d.TiledSurfaceRenderer; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HypersurfaceCameraController; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.SurfaceHeightOrientation; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.SurfaceRowOrientation; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.SurfaceUtils; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.TiledLodManager; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.TiledSurfaceRenderer; import edu.jhuapl.trinity.utils.DataUtils.HeightMode; import javafx.animation.AnimationTimer; import javafx.event.Event; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; +import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.ColorPicker; import javafx.scene.control.ComboBox; @@ -58,6 +60,14 @@ public class HypersurfaceControlsPane extends LitPathPane { private Spinner surfScaleSpinner; private Spinner xWidthSpinner; private Spinner zWidthSpinner; + private Label xWidthLabel; + private Label zWidthLabel; + private Label sourceDimensionsCaption; + private Label sourceDimensionsLabel; + private Label renderL0DimensionsCaption; + private Label renderL0DimensionsLabel; + private Label worldDimensionsCaption; + private Label worldDimensionsLabel; // Rendering private ComboBox meshTypeCombo; @@ -84,7 +94,8 @@ public class HypersurfaceControlsPane extends LitPathPane { private CheckBox enableAmbientCheck; private ColorPicker ambientColorPicker; private CheckBox enablePointCheck; - private ColorPicker specularColorPicker; + private ColorPicker pointColorPicker; + private CheckBox showLegendCheck; // Graph overlay visibility private ToggleButton showGraphToggle; @@ -134,14 +145,26 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar // --- GUI sync from model → controls scene.addEventHandler(HypersurfaceEvent.SET_XWIDTH_GUI, e -> { - if (xWidthSpinner != null && !xWidthSpinner.getValue().equals(e.object)) { - xWidthSpinner.getValueFactory().setValue((Integer) e.object); + if (xWidthSpinner != null && e.object instanceof Integer value) { + if (xWidthSpinner.getValueFactory() instanceof SpinnerValueFactory.IntegerSpinnerValueFactory vf + && value > vf.getMax()) { + vf.setMax(value); + } + if (!xWidthSpinner.getValue().equals(value)) { + xWidthSpinner.getValueFactory().setValue(value); + } } e.consume(); }); scene.addEventHandler(HypersurfaceEvent.SET_ZWIDTH_GUI, e -> { - if (zWidthSpinner != null && !zWidthSpinner.getValue().equals(e.object)) { - zWidthSpinner.getValueFactory().setValue((Integer) e.object); + if (zWidthSpinner != null && e.object instanceof Integer value) { + if (zWidthSpinner.getValueFactory() instanceof SpinnerValueFactory.IntegerSpinnerValueFactory vf + && value > vf.getMax()) { + vf.setMax(value); + } + if (!zWidthSpinner.getValue().equals(value)) { + zWidthSpinner.getValueFactory().setValue(value); + } } e.consume(); }); @@ -158,6 +181,14 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar e.consume(); }); + if (target != null) { + target.legendEnabledProperty().addListener((obs, oldValue, newValue) -> { + if (showLegendCheck != null && showLegendCheck.isSelected() != newValue) { + showLegendCheck.setSelected(newValue); + } + }); + } + // Graph overlay visibility GUI sync scene.addEventHandler(GraphEvent.SET_GRAPH_VISIBILITY_GUI, e -> { if (showGraphToggle != null) { @@ -194,10 +225,23 @@ private TabPane buildTabs() { yScaleSpinner.setEditable(true); surfScaleSpinner.setEditable(true); - addRow(dimsGrid, 0, "X width", xWidthSpinner); - addRow(dimsGrid, 1, "Z length", zWidthSpinner); - addRow(dimsGrid, 2, "Y scale", yScaleSpinner); - addRow(dimsGrid, 3, "Range scale", surfScaleSpinner); + xWidthLabel = new Label("X width"); + zWidthLabel = new Label("Z length"); + sourceDimensionsCaption = new Label("Source dimensions"); + sourceDimensionsLabel = new Label("-"); + renderL0DimensionsCaption = new Label("Render L0"); + renderL0DimensionsLabel = new Label("-"); + worldDimensionsCaption = new Label("World dimensions"); + worldDimensionsLabel = new Label("-"); + + addRow(dimsGrid, 0, xWidthLabel, xWidthSpinner); + addRow(dimsGrid, 1, zWidthLabel, zWidthSpinner); + addRow(dimsGrid, 2, sourceDimensionsCaption, sourceDimensionsLabel); + addRow(dimsGrid, 3, renderL0DimensionsCaption, renderL0DimensionsLabel); + addRow(dimsGrid, 4, worldDimensionsCaption, worldDimensionsLabel); + addRow(dimsGrid, 5, "Y scale", yScaleSpinner); + addRow(dimsGrid, 6, "Range scale", surfScaleSpinner); + refreshDimensionControls(); // Core: Spinner value listeners xWidthSpinner.valueProperty().addListener((obs, oldVal, newVal) -> @@ -321,19 +365,57 @@ private TabPane buildTabs() { enablePointCheck = new CheckBox("Point light"); enablePointCheck.setSelected(true); styleCheck(enablePointCheck); - specularColorPicker = new ColorPicker(Color.CYAN); - styleColorPicker(specularColorPicker); - - HBox pointBox = new HBox(6, enablePointCheck, specularColorPicker); - addRow(sceneGrid, 3, "Point", pointBox); + pointColorPicker = new ColorPicker(Color.WHITE); + styleColorPicker(pointColorPicker); + HBox pointBox = new HBox(6, enablePointCheck, pointColorPicker); + addRow(sceneGrid, 3, "Point Light", pointBox); enablePointCheck.setOnAction(e -> { boolean on = enablePointCheck.isSelected(); - specularColorPicker.setDisable(!on); + pointColorPicker.setDisable(!on); fireOnRoot(HypersurfaceEvent.pointEnabled(on)); }); - specularColorPicker.setOnAction(e -> - fireOnRoot(HypersurfaceEvent.specularColor(specularColorPicker.getValue()))); + pointColorPicker.setOnAction(e -> + fireOnRoot(HypersurfaceEvent.pointColor(pointColorPicker.getValue()))); + + ComboBox cameraViewCombo = new ComboBox<>(); + cameraViewCombo.getItems().addAll("Fit", "Oblique", "Top", "Front", "Side"); + cameraViewCombo.getSelectionModel().select("Fit"); + cameraViewCombo.setPrefWidth(125.0); + Button applyCameraButton = new Button("Apply"); + showLegendCheck = new CheckBox("Legend"); + showLegendCheck.setSelected(target == null || target.isLegendEnabled()); + showLegendCheck.setPrefWidth(76.0); + showLegendCheck.setMinWidth(Region.USE_PREF_SIZE); + HBox cameraBox = new HBox(6, cameraViewCombo, applyCameraButton, showLegendCheck); + cameraBox.setAlignment(Pos.CENTER_LEFT); + addRow(sceneGrid, 4, "Camera", cameraBox); + + showLegendCheck.setOnAction(e -> { + if (target != null) target.setLegendEnabled(showLegendCheck.isSelected()); + }); + + applyCameraButton.setOnAction(e -> { + if (target == null) return; + String selection = cameraViewCombo.getValue(); + if (selection == null) return; + switch (selection) { + case "Fit" -> target.fitCameraView(Hypersurface3DPane.DEFAULT_ZOOM_TIME_MS); + case "Oblique" -> target.applyCameraPreset( + HypersurfaceCameraController.Preset.OBLIQUE, + Hypersurface3DPane.DEFAULT_ZOOM_TIME_MS); + case "Top" -> target.applyCameraPreset( + HypersurfaceCameraController.Preset.TOP, + Hypersurface3DPane.DEFAULT_ZOOM_TIME_MS); + case "Front" -> target.applyCameraPreset( + HypersurfaceCameraController.Preset.FRONT, + Hypersurface3DPane.DEFAULT_ZOOM_TIME_MS); + case "Side" -> target.applyCameraPreset( + HypersurfaceCameraController.Preset.SIDE, + Hypersurface3DPane.DEFAULT_ZOOM_TIME_MS); + default -> { } + } + }); // --- Graph Overlay visibility --- GridPane graphOverlayGrid = formGrid(); @@ -347,8 +429,7 @@ private TabPane buildTabs() { VBox viewTabContent = new VBox(10, titledBox("Dimensions", dimsGrid), titledBox("Rendering", renderGrid), - titledBox("Scene", sceneGrid), - titledBox("Graph Overlay", graphOverlayGrid) + titledBox("Scene", sceneGrid) ); viewTabContent.setPadding(new Insets(6)); @@ -605,12 +686,17 @@ private TabPane buildTabs() { GraphControlsView graphLayoutView = new GraphControlsView(scene); GraphStyleControlsView graphStyleView = new GraphStyleControlsView(scene); + VBox graphStyleTabContent = new VBox(10, + titledBox("Graph Overlay", graphOverlayGrid), + graphStyleView); + graphStyleTabContent.setPadding(new Insets(6)); + VBox.setVgrow(graphStyleView, Priority.ALWAYS); Tab t1 = new Tab("View", viewTabContent); Tab t2 = new Tab("Processing", procTabContent); Tab t3 = new Tab("LOD", lodScrollPane); Tab t4 = new Tab("Graph Layout", graphLayoutView); - Tab t5 = new Tab("Graph Style", graphStyleView); + Tab t5 = new Tab("Graph Style", graphStyleTabContent); tabs.getTabs().addAll(t1, t2, t3, t4, t5); return tabs; @@ -670,6 +756,7 @@ public void handle(long now) { } private void refreshLodDiagnostics() { + refreshDimensionControls(); if (target == null || lodStatusLabel == null) return; TiledSurfaceRenderer.LodStatistics stats = target.getTiledLodStatistics(); lodStatusLabel.setText(target.isTiledHeightFieldRendererActive() ? "Active" : "Inactive"); @@ -686,6 +773,43 @@ private void refreshLodDiagnostics() { pendingTransitionsLabel.setText(Integer.toString(stats.pendingTransitions())); } + private void refreshDimensionControls() { + if (target == null + || xWidthSpinner == null + || zWidthSpinner == null + || sourceDimensionsLabel == null) { + return; + } + + boolean imageBacked = target.isImageBackedSurface(); + setManagedVisible(xWidthLabel, !imageBacked); + setManagedVisible(xWidthSpinner, !imageBacked); + setManagedVisible(zWidthLabel, !imageBacked); + setManagedVisible(zWidthSpinner, !imageBacked); + + setManagedVisible(sourceDimensionsCaption, imageBacked); + setManagedVisible(sourceDimensionsLabel, imageBacked); + setManagedVisible(renderL0DimensionsCaption, imageBacked); + setManagedVisible(renderL0DimensionsLabel, imageBacked); + setManagedVisible(worldDimensionsCaption, imageBacked); + setManagedVisible(worldDimensionsLabel, imageBacked); + + if (imageBacked) { + sourceDimensionsLabel.setText( + target.getSourceWidth() + " x " + target.getSourceHeight()); + renderL0DimensionsLabel.setText( + target.getRenderL0Width() + " x " + target.getRenderL0Height()); + worldDimensionsLabel.setText(String.format( + "%.1f x %.1f", target.getWorldWidth(), target.getWorldDepth())); + } + } + + private static void setManagedVisible(javafx.scene.Node node, boolean visible) { + if (node == null) return; + node.setManaged(visible); + node.setVisible(visible); + } + private static String formatTriangleCount(long triangles) { if (triangles >= 1_000_000L) { return String.format("%.2f M", triangles / 1_000_000.0); @@ -730,8 +854,11 @@ private static GridPane formGrid() { } private static void addRow(GridPane gp, int row, String label, javafx.scene.Node control) { - Label l = new Label(label); - gp.add(l, 0, row); + addRow(gp, row, new Label(label), control); + } + + private static void addRow(GridPane gp, int row, Label label, javafx.scene.Node control) { + gp.add(label, 0, row); if (control instanceof Spinner || control instanceof ComboBox) { GridPane.setHgrow(control, Priority.NEVER); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/NavigatorPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/NavigatorPane.java index 107e24d0..0ca76b48 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/NavigatorPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/NavigatorPane.java @@ -6,6 +6,8 @@ import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; import edu.jhuapl.trinity.javafx.events.ImageEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.SurfaceInspection; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.application.Platform; import javafx.geometry.Insets; @@ -25,9 +27,13 @@ import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.media.MediaView; import javafx.scene.paint.Color; +import javafx.scene.shape.Circle; +import javafx.scene.shape.Line; +import javafx.scene.shape.Rectangle; import javafx.scene.text.Font; import javafx.scene.text.Text; import org.slf4j.Logger; @@ -60,6 +66,27 @@ public class NavigatorPane extends LitPathPane { TitledPane metaTP; GridPane detailsGridPane; ImageView imageView; + StackPane imageStack; + Pane imageOverlayPane; + Line imageCrosshairHorizontal; + Line imageCrosshairVertical; + Circle imagePositionMarker; + Rectangle imageTileOutline; + TitledPane surfaceTP; + GridPane surfaceGridPane; + Label surfacePositionKey; + Label surfacePixelValue; + Label surfaceRenderCellValue; + Label surfaceValueValue; + Label surfaceRowRangeValue; + Label surfaceColumnRangeValue; + Label surfaceWorldValue; + Label surfaceRgbValue; + Label surfaceIntensityValue; + Label surfaceTileValue; + Label surfaceLodValue; + Label surfaceTileBoundsValue; + SurfaceInspection currentSurfaceInspection; TextArea textArea; VBox contentVBox; @@ -82,6 +109,43 @@ public NavigatorPane(Scene scene, Pane parent) { imageView.setFitHeight(DEFAULT_FIT_WIDTH); imageView.setPreserveRatio(true); + imageOverlayPane = new Pane(); + imageOverlayPane.setMouseTransparent(true); + imageOverlayPane.setPickOnBounds(false); + + imageCrosshairHorizontal = new Line(); + imageCrosshairHorizontal.setStroke(Color.CYAN); + imageCrosshairHorizontal.setStrokeWidth(1.25); + imageCrosshairHorizontal.setMouseTransparent(true); + imageCrosshairVertical = new Line(); + imageCrosshairVertical.setStroke(Color.CYAN); + imageCrosshairVertical.setStrokeWidth(1.25); + imageCrosshairVertical.setMouseTransparent(true); + + imagePositionMarker = new Circle(4.0, Color.TRANSPARENT); + imagePositionMarker.setStroke(Color.WHITE); + imagePositionMarker.setStrokeWidth(1.5); + imagePositionMarker.setMouseTransparent(true); + + imageTileOutline = new Rectangle(); + imageTileOutline.setFill(Color.TRANSPARENT); + imageTileOutline.setStroke(Color.YELLOW); + imageTileOutline.setStrokeWidth(1.5); + imageTileOutline.getStrokeDashArray().setAll(8.0, 5.0); + imageTileOutline.setMouseTransparent(true); + + imageOverlayPane.getChildren().addAll( + imageTileOutline, imageCrosshairHorizontal, imageCrosshairVertical, imagePositionMarker); + setImageInspectionOverlayVisible(false); + + imageStack = new StackPane(imageView, imageOverlayPane); + imageStack.setAlignment(Pos.CENTER); + imageStack.setPrefSize(DEFAULT_FIT_WIDTH, DEFAULT_FIT_WIDTH); + imageStack.setMinSize(DEFAULT_FIT_WIDTH, DEFAULT_FIT_WIDTH); + imageStack.setMaxSize(DEFAULT_FIT_WIDTH, DEFAULT_FIT_WIDTH); + imageStack.widthProperty().addListener((obs, oldValue, newValue) -> refreshImageInspectionOverlay()); + imageStack.heightProperty().addListener((obs, oldValue, newValue) -> refreshImageInspectionOverlay()); + textArea = new TextArea(); textArea.setPrefWidth(DEFAULT_FIT_WIDTH); textArea.setPrefHeight(DEFAULT_FIT_WIDTH); @@ -116,9 +180,35 @@ public NavigatorPane(Scene scene, Pane parent) { metaTP.setText("Metadata"); metaTP.setExpanded(false); metaTP.setPrefWidth(DEFAULT_TITLEDPANE_WIDTH); + + surfaceGridPane = new GridPane(); + surfaceGridPane.setPadding(new Insets(4)); + surfaceGridPane.setHgap(8); + surfaceGridPane.setVgap(2); + surfacePositionKey = new Label("Source Pixel"); + surfacePositionKey.setMinWidth(100); + surfacePixelValue = new Label("-"); + surfacePixelValue.setWrapText(true); + surfaceGridPane.addRow(0, surfacePositionKey, surfacePixelValue); + surfaceRenderCellValue = addSurfaceRow(1, "Render Cell"); + surfaceValueValue = addSurfaceRow(2, "Value"); + surfaceRowRangeValue = addSurfaceRow(3, "Row Range"); + surfaceColumnRangeValue = addSurfaceRow(4, "Column Range"); + surfaceWorldValue = addSurfaceRow(5, "World XYZ"); + surfaceRgbValue = addSurfaceRow(6, "RGB"); + surfaceIntensityValue = addSurfaceRow(7, "Intensity"); + surfaceTileValue = addSurfaceRow(8, "Tile"); + surfaceLodValue = addSurfaceRow(9, "LOD"); + surfaceTileBoundsValue = addSurfaceRow(10, "Tile Bounds"); + surfaceTP = new TitledPane("Surface", surfaceGridPane); + surfaceTP.setExpanded(true); + surfaceTP.setPrefWidth(DEFAULT_TITLEDPANE_WIDTH); + surfaceTP.setVisible(false); + surfaceTP.setManaged(false); + Tab imageTab = new Tab("Image"); imageTab.setClosable(false); - imageTab.setContent(imageView); + imageTab.setContent(imageStack); Tab textTab = new Tab("Text"); textTab.setClosable(false); textTab.setContent(textArea); @@ -127,7 +217,7 @@ public NavigatorPane(Scene scene, Pane parent) { contentVBox = new VBox(5, tabPane, urlLabel, imageLabel, new HBox(10, hypersurfaceButton, imageInspectionButton), - detailsTP, metaTP); + surfaceTP, detailsTP, metaTP); ImageView refresh = ResourceUtils.loadIcon("refresh", 32); @@ -190,6 +280,177 @@ public NavigatorPane(Scene scene, Pane parent) { } createDetails(fv); }); + scene.addEventHandler(HypersurfaceEvent.SURFACE_SOURCE_IMAGE_CHANGED, e -> { + if (e.object instanceof Image image) { + setImage(image); + imageLabel.setText("Hypersurface Source"); + String imageUrl = image.getUrl(); + if (imageUrl != null && !imageUrl.isBlank()) { + urlLabel.setText(imageUrl); + urlLabel.setTooltip(new Tooltip(imageUrl)); + } else { + String dimensions = (int) image.getWidth() + " x " + (int) image.getHeight(); + urlLabel.setText(dimensions); + urlLabel.setTooltip(new Tooltip("Hypersurface source image: " + dimensions)); + } + } + }); + scene.addEventHandler(HypersurfaceEvent.SURFACE_INSPECTION_UPDATED, e -> { + if (e.object instanceof SurfaceInspection inspection) { + updateSurfaceInspection(inspection); + } else { + clearSurfaceInspection(); + } + }); + } + + private Label addSurfaceRow(int row, String name) { + Label key = new Label(name); + key.setMinWidth(100); + Label value = new Label("-"); + value.setWrapText(true); + surfaceGridPane.addRow(row, key, value); + return value; + } + + private void updateSurfaceInspection(SurfaceInspection inspection) { + currentSurfaceInspection = inspection; + surfaceTP.setManaged(true); + surfaceTP.setVisible(true); + + int displayX = inspection.imageBacked() ? inspection.imageColumn() : inspection.sourceColumn(); + int displayY = inspection.imageBacked() ? inspection.imageRow() : inspection.sourceRow(); + surfacePositionKey.setText(inspection.imageBacked() ? "Source Pixel" : "Source Cell"); + surfacePixelValue.setText(displayX + ", " + displayY); + surfaceRenderCellValue.setText(inspection.renderColumn() + ", " + inspection.renderRow()); + surfaceValueValue.setText(formatNumber(inspection.surfaceValue())); + surfaceRowRangeValue.setText(formatRange(inspection.rowMinimum(), inspection.rowMaximum())); + surfaceColumnRangeValue.setText(formatRange( + inspection.columnMinimum(), inspection.columnMaximum())); + surfaceWorldValue.setText(formatNumber(inspection.worldX()) + ", " + + formatNumber(inspection.worldY()) + ", " + formatNumber(inspection.worldZ())); + + if (inspection.hasImageSample()) { + surfaceRgbValue.setText(inspection.red() + ", " + inspection.green() + ", " + + inspection.blue()); + surfaceIntensityValue.setText(formatNumber(inspection.imageIntensity())); + } else { + surfaceRgbValue.setText("-"); + surfaceIntensityValue.setText("-"); + } + + if (inspection.hasTile()) { + surfaceTileValue.setText(Integer.toString(inspection.tileId())); + surfaceLodValue.setText(inspection.activeLod() >= 0 + ? "L" + inspection.activeLod() : "pending"); + surfaceTileBoundsValue.setText( + inspection.tileImageMinX() + "-" + (inspection.tileImageMaxXExclusive() - 1) + + " x " + inspection.tileImageMinY() + "-" + + (inspection.tileImageMaxYExclusive() - 1)); + } else { + surfaceTileValue.setText("-"); + surfaceLodValue.setText("-"); + surfaceTileBoundsValue.setText("-"); + } + refreshImageInspectionOverlay(); + } + + private void clearSurfaceInspection() { + currentSurfaceInspection = null; + surfaceTP.setVisible(false); + surfaceTP.setManaged(false); + setImageInspectionOverlayVisible(false); + } + + private void refreshImageInspectionOverlay() { + SurfaceInspection inspection = currentSurfaceInspection; + Image image = imageView.getImage(); + if (inspection == null || !inspection.imageBacked() || image == null + || !(image.getWidth() > 0.0) || !(image.getHeight() > 0.0)) { + setImageInspectionOverlayVisible(false); + return; + } + + double stackWidth = imageStack.getWidth() > 0.0 + ? imageStack.getWidth() : DEFAULT_FIT_WIDTH; + double stackHeight = imageStack.getHeight() > 0.0 + ? imageStack.getHeight() : DEFAULT_FIT_WIDTH; + double scale = Math.min( + imageView.getFitWidth() / image.getWidth(), + imageView.getFitHeight() / image.getHeight()); + if (!Double.isFinite(scale) || !(scale > 0.0)) { + setImageInspectionOverlayVisible(false); + return; + } + + double displayedWidth = image.getWidth() * scale; + double displayedHeight = image.getHeight() * scale; + double originX = (stackWidth - displayedWidth) * 0.5; + double originY = (stackHeight - displayedHeight) * 0.5; + + double markerX = originX + + ((inspection.imageColumn() + 0.5) / image.getWidth()) * displayedWidth; + double markerY = originY + + ((inspection.imageRow() + 0.5) / image.getHeight()) * displayedHeight; + markerX = clamp(markerX, originX, originX + displayedWidth); + markerY = clamp(markerY, originY, originY + displayedHeight); + + imageCrosshairHorizontal.setStartX(originX); + imageCrosshairHorizontal.setEndX(originX + displayedWidth); + imageCrosshairHorizontal.setStartY(markerY); + imageCrosshairHorizontal.setEndY(markerY); + imageCrosshairVertical.setStartX(markerX); + imageCrosshairVertical.setEndX(markerX); + imageCrosshairVertical.setStartY(originY); + imageCrosshairVertical.setEndY(originY + displayedHeight); + imagePositionMarker.setCenterX(markerX); + imagePositionMarker.setCenterY(markerY); + + if (inspection.hasTile()) { + double tileMinX = clamp(inspection.tileImageMinX(), 0.0, image.getWidth()); + double tileMinY = clamp(inspection.tileImageMinY(), 0.0, image.getHeight()); + double tileMaxX = clamp(inspection.tileImageMaxXExclusive(), 0.0, image.getWidth()); + double tileMaxY = clamp(inspection.tileImageMaxYExclusive(), 0.0, image.getHeight()); + double tileX = originX + tileMinX / image.getWidth() * displayedWidth; + double tileY = originY + tileMinY / image.getHeight() * displayedHeight; + double tileWidth = (tileMaxX - tileMinX) / image.getWidth() * displayedWidth; + double tileHeight = (tileMaxY - tileMinY) / image.getHeight() * displayedHeight; + imageTileOutline.setX(tileX); + imageTileOutline.setY(tileY); + imageTileOutline.setWidth(tileWidth); + imageTileOutline.setHeight(tileHeight); + imageTileOutline.setVisible(true); + } else { + imageTileOutline.setVisible(false); + } + + imageCrosshairHorizontal.setVisible(true); + imageCrosshairVertical.setVisible(true); + imagePositionMarker.setVisible(true); + } + + private void setImageInspectionOverlayVisible(boolean visible) { + imageCrosshairHorizontal.setVisible(visible); + imageCrosshairVertical.setVisible(visible); + imagePositionMarker.setVisible(visible); + imageTileOutline.setVisible(visible); + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(value, maximum)); + } + + private static String formatRange(double minimum, double maximum) { + return formatNumber(minimum) + " - " + formatNumber(maximum); + } + + private static String formatNumber(double value) { + if (!Double.isFinite(value)) return "-"; + double abs = Math.abs(value); + if (abs > 0.0 && (abs < 0.001 || abs >= 10000.0)) { + return String.format("%.3e", value); + } + return String.format("%.4f", value); } private void createDetails(FeatureVector featureVector) { @@ -239,5 +500,6 @@ public void toggleAuto() { public void setImage(Image image) { currentImage = image; imageView.setImage(currentImage); + Platform.runLater(this::refreshImageInspectionOverlay); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java index 8f790075..625dcc59 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/SurfaceChartPane.java @@ -42,10 +42,10 @@ public SurfaceChartPane(Scene scene, Pane parent) { bp.setCenter(tabPane); this.scene.getRoot().addEventHandler(FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, e -> { - fcb.setFactorVector(fcb.xFactorVector, (Double[]) e.object1); + fcb.setXFactorVector((Double[]) e.object1); }); this.scene.getRoot().addEventHandler(FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, e -> { - fcb.setFactorVector(fcb.zFactorVector, (Double[]) e.object1); + fcb.setZFactorVector((Double[]) e.object1); }); this.scene.getRoot().addEventHandler(FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, e -> { avb.setAnalysisVector((String) e.object1, (Double[]) e.object2); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java index e049cbd4..8d8865ce 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -81,6 +81,8 @@ public class HypersurfaceEvent extends Event { new EventType<>(ANY, "HYPERSURF_AMBIENT_COLOR_CHANGED"); // Color public static final EventType POINT_ENABLED_CHANGED = new EventType<>(ANY, "HYPERSURF_POINT_ENABLED_CHANGED"); // Boolean + public static final EventType POINT_COLOR_CHANGED = + new EventType<>(ANY, "HYPERSURF_POINT_COLOR_CHANGED"); // Color public static final EventType SPECULAR_COLOR_CHANGED = new EventType<>(ANY, "HYPERSURF_SPECULAR_COLOR_CHANGED"); // Color @@ -93,6 +95,10 @@ public class HypersurfaceEvent extends Event { new EventType<>(ANY, "HYPERSURF_DATA_MARKERS_ENABLE_CHANGED"); // Boolean public static final EventType CROSSHAIRS_ENABLE_CHANGED = new EventType<>(ANY, "HYPERSURF_CROSSHAIRS_ENABLE_CHANGED"); // Boolean + public static final EventType SURFACE_SOURCE_IMAGE_CHANGED = + new EventType<>(ANY, "HYPERSURF_SURFACE_SOURCE_IMAGE_CHANGED"); // Image + public static final EventType SURFACE_INSPECTION_UPDATED = + new EventType<>(ANY, "HYPERSURF_SURFACE_INSPECTION_UPDATED"); // SurfaceInspection or null // --- Commands / actions --- public static final EventType RESET_VIEW = @@ -215,6 +221,10 @@ public static HypersurfaceEvent pointEnabled(boolean b) { return of(POINT_ENABLED_CHANGED, b); } + public static HypersurfaceEvent pointColor(Object color) { + return of(POINT_COLOR_CHANGED, color); + } + public static HypersurfaceEvent specularColor(Object color) { return of(SPECULAR_COLOR_CHANGED, color); } @@ -235,6 +245,14 @@ public static HypersurfaceEvent crosshairsEnabled(boolean b) { return of(CROSSHAIRS_ENABLE_CHANGED, b); } + public static HypersurfaceEvent sourceImageChanged(Object image) { + return of(SURFACE_SOURCE_IMAGE_CHANGED, image); + } + + public static HypersurfaceEvent surfaceInspection(Object inspection) { + return of(SURFACE_INSPECTION_UPDATED, inspection); + } + public static HypersurfaceEvent resetView() { return of(RESET_VIEW); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java deleted file mode 100644 index 3f63e5b9..00000000 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ /dev/null @@ -1,3185 +0,0 @@ -package edu.jhuapl.trinity.javafx.javafx3d; - -import edu.jhuapl.trinity.App; -import edu.jhuapl.trinity.data.CoordinateSet; -import edu.jhuapl.trinity.data.files.FeatureCollectionFile; -import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; -import edu.jhuapl.trinity.data.graph.GraphEdge; -import edu.jhuapl.trinity.data.graph.GraphNode; -import edu.jhuapl.trinity.data.messages.bci.SemanticMap; -import edu.jhuapl.trinity.data.messages.bci.SemanticMapCollection; -import edu.jhuapl.trinity.data.messages.bci.SemanticReconstruction; -import edu.jhuapl.trinity.data.messages.bci.SemanticReconstructionMap; -import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; -import edu.jhuapl.trinity.data.messages.xai.FeatureVector; -import edu.jhuapl.trinity.data.messages.xai.ShapleyCollection; -import edu.jhuapl.trinity.data.messages.xai.ShapleyVector; -import edu.jhuapl.trinity.javafx.components.callouts.Callout; -import edu.jhuapl.trinity.javafx.components.callouts.CalloutBuilder; -import edu.jhuapl.trinity.javafx.components.panes.SurfaceChartPane; -import edu.jhuapl.trinity.javafx.events.ApplicationEvent; -import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; -import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; -import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; -import edu.jhuapl.trinity.javafx.events.GraphEvent; -import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; -import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; -import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; -import edu.jhuapl.trinity.javafx.events.ImageEvent; -import edu.jhuapl.trinity.javafx.events.ManifoldEvent; -import edu.jhuapl.trinity.javafx.events.ShadowEvent; -import edu.jhuapl.trinity.javafx.events.TimelineEvent; -import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; -import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; -import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; -import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.ExMaxClusterTask; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.HDDBSCANClusterTask; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMeansClusterTask; -import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMediodsClusterTask; -import edu.jhuapl.trinity.javafx.renderers.FeatureVectorRenderer; -import edu.jhuapl.trinity.javafx.renderers.Graph3DRenderer; -import edu.jhuapl.trinity.javafx.renderers.SemanticMapRenderer; -import edu.jhuapl.trinity.javafx.renderers.ShapleyVectorRenderer; -import edu.jhuapl.trinity.utils.DataUtils; -import edu.jhuapl.trinity.utils.DataUtils.HeightMode; -import edu.jhuapl.trinity.utils.JavaFX3DUtils; -import edu.jhuapl.trinity.utils.ResourceUtils; -import edu.jhuapl.trinity.utils.Utils; -import edu.jhuapl.trinity.utils.graph.GraphStyleParams; -import edu.jhuapl.trinity.utils.metric.Metric; -import edu.jhuapl.trinity.utils.statistics.GridDensityResult; -import javafx.animation.AnimationTimer; -import javafx.animation.KeyFrame; -import javafx.animation.KeyValue; -import javafx.animation.Timeline; -import javafx.application.Platform; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.embed.swing.SwingFXUtils; -import javafx.event.ActionEvent; -import javafx.event.Event; -import javafx.geometry.Point2D; -import javafx.scene.AmbientLight; -import javafx.scene.Group; -import javafx.scene.Node; -import javafx.scene.Parent; -import javafx.scene.PerspectiveCamera; -import javafx.scene.PointLight; -import javafx.scene.Scene; -import javafx.scene.SceneAntialiasing; -import javafx.scene.SnapshotParameters; -import javafx.scene.SubScene; -import javafx.scene.control.Alert; -import javafx.scene.control.ButtonType; -import javafx.scene.control.CheckMenuItem; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.DialogPane; -import javafx.scene.control.Label; -import javafx.scene.control.Menu; -import javafx.scene.control.MenuItem; -import javafx.scene.control.TitledPane; -import javafx.scene.effect.Glow; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -import javafx.scene.image.PixelReader; -import javafx.scene.image.PixelWriter; -import javafx.scene.image.WritableImage; -import javafx.scene.input.Clipboard; -import javafx.scene.input.ClipboardContent; -import javafx.scene.input.KeyCode; -import javafx.scene.input.MouseButton; -import javafx.scene.input.MouseEvent; -import javafx.scene.input.ScrollEvent; -import javafx.scene.layout.Background; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.Pane; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; -import javafx.scene.paint.PhongMaterial; -import javafx.scene.shape.Box; -import javafx.scene.shape.CullFace; -import javafx.scene.shape.Cylinder; -import javafx.scene.shape.DrawMode; -import javafx.scene.shape.MeshView; -import javafx.scene.shape.Shape3D; -import javafx.scene.shape.Sphere; -import javafx.scene.shape.TriangleMesh; -import javafx.scene.text.Font; -import javafx.scene.text.Text; -import javafx.scene.transform.Rotate; -import javafx.scene.transform.Translate; -import javafx.stage.FileChooser; -import javafx.stage.StageStyle; -import javafx.util.Duration; -import org.fxyz3d.geometry.Point3D; -import org.fxyz3d.scene.Skybox; -import org.fxyz3d.utils.CameraTransformer; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.imageio.ImageIO; -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Random; -import java.util.function.Function; - -/** - * @author Sean Phillips - */ -public class Hypersurface3DPane extends StackPane - implements SemanticMapRenderer, FeatureVectorRenderer, ShapleyVectorRenderer { - - private static final Logger LOG = LoggerFactory.getLogger(Hypersurface3DPane.class); - public static double ICON_FIT_HEIGHT = 64; - public static double DEFAULT_INTRO_DISTANCE = -30000.0; - public static double DEFAULT_ZOOM_TIME_MS = 500.0; - public static double CHIP_FIT_WIDTH = 200; - public static int DEFAULT_XWIDTH = 200; - public static int DEFAULT_ZWIDTH = 200; - public static int DEFAULT_SURFSCALE = 5; - public static int DEFAULT_YSCALE = 5; - - public PerspectiveCamera camera; - public CameraTransformer cameraTransform = new CameraTransformer(); - public XFormGroup dataXForm = new XFormGroup(); - - private double cameraDistance = -1000; - private final double sceneWidth = 4000; - private final double sceneHeight = 4000; - private final double planeSize = sceneWidth / 2.0; - - private double mousePosX; - private double mousePosY; - private double mouseOldX; - private double mouseOldY; - private double mouseDeltaX; - private double mouseDeltaY; - - public Group sceneRoot = new Group(); - public Group extrasGroup = new Group(); - public Group debugGroup = new Group(); - public Group ellipsoidGroup = new Group(); - public SubScene subScene; - - public long hypersurfaceRefreshRate = 500; //milliseconds - public int queueLimit = 20000; - - //feature vector indices for 3D coordinates - private int xFactorIndex = 0; - private int yFactorIndex = 1; - private int zFactorIndex = 2; - private int factorMaxIndex = 512; - - public Color sceneColor = Color.BLACK; - boolean isDirty = false; - boolean computeRandos = false; - boolean animated = false; - boolean heightChanged = false; - public boolean surfaceRender = true; - - public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} - - COLORATION colorationMethod = COLORATION.COLOR_BY_FEATURE; - private final ObjectProperty surfaceRowOrientation = - new SimpleObjectProperty<>(SurfaceRowOrientation.FIRST_ROW_NEAR); - private final ObjectProperty surfaceHeightOrientation = - new SimpleObjectProperty<>(SurfaceHeightOrientation.HIGH_VALUES_UP); - private boolean suppressRowOrientationRefresh = false; - boolean hoverInteractionsEnabled = false; - boolean surfaceChartsEnabled = false; - boolean crosshairsEnabled = false; - - //Shapley value support - private Image lastImage = null; - private String lastImageSource = null; - public List shapleyVectors = new ArrayList<>(); - - WritableImage diffusePaintImage; - PhongMaterial paintPhong; - TriangleMesh paintTriangleMesh; - MeshView paintMeshView; - - //allows 2D labels to track their 3D counterparts - HashMap shape3DToLabel = new HashMap<>(); - public List featureVectors = new ArrayList<>(); - - /** - * Authoritative boxed working/source grid retained for compatibility with existing - * analysis and ingestion paths. LOD selection must never replace this with a - * downsampled render grid; activeHeightField owns render-side data. - */ - public List> dataGrid = new ArrayList<>(); - - private Random rando = new Random(); - public HyperSurfacePlotMesh surfPlot; - - public int xWidth = DEFAULT_XWIDTH; - public int zWidth = DEFAULT_ZWIDTH; - public float yScale = DEFAULT_YSCALE; - public float surfScale = DEFAULT_SURFSCALE; - - int TOTAL_COLORS = 1530; //colors used by map function - Function colorByHeight = - p -> getSurfaceHeightOrientation().toLogicalHeight(p.y); // orientation-neutral color mapping - Function colorByShapley = p -> p.f; - - Function vert3DLookup = p -> vertToHeight(p); - - // initial rotation - private final Rotate rotateX = new Rotate(0, Rotate.X_AXIS); - private final Rotate rotateY = new Rotate(0, Rotate.Y_AXIS); - private final Rotate rotateZ = new Rotate(0, Rotate.Z_AXIS); - - private Skybox skybox; - private Group nodeGroup = new Group(); - private Group labelGroup = new Group(); - - BorderPane bp; - Box glowLineBox; - Cylinder eastPole, westPole; - Sphere eastKnob, westKnob; - Label eastLabel, westLabel; - int anchorIndex = 0; - Callout anchorCallout; - - //For each label you'll need some Shape3D to derive a point3d from. - private Sphere xSphere = new Sphere(10); - private Sphere ySphere = new Sphere(10); - private Sphere zSphere = new Sphere(10); - Sphere highlightedPoint = new Sphere(2, 32); - private Label xLabel = new Label("Features (ordered)"); - private Label yLabel = new Label("Magnitude"); - private Label zLabel = new Label("Time (Samples)"); - Text hoverText = new Text("Coordinates: "); - - public List featureLabels = new ArrayList<>(); - public Scene scene; - HashMap shape3DToCalloutMap; - public String imageryBasePath = ""; - SurfaceChartPane surfaceChartPane; - public AmbientLight ambientLight; - public PointLight pointLight; - private List> originalGrid = new ArrayList<>(); - - - - // ============================================================ - // Primitive HeightField + Global LOD Cap (via LodManager) - // ============================================================ - - /** Max render dimension for L0 (aspect-preserving). Default safe limit: 2048. */ - private int maxRenderResolution = 2048; - /** Minimum LOD dimension for the legacy/global renderer. Default: 512. */ - private int minRenderResolution = 512; - - /** Tiled image rendering uses a fixed L0..L4 power-of-two pyramid. */ - private static final int TILED_LOD_LEVEL_COUNT = 5; - - /** Processing source HeightField. Image sources retain only capped raw L0; data grids retain full source resolution. */ - private HeightField processingSourceHeightField = null; - // Image-backed surfaces retain only the capped raw L0 HeightField. The original - // source dimensions/region are tracked separately so world extents and hover/source - // semantics remain tied to the input image rather than the render cap. - private int imageSourceStartX = 0; - private int imageSourceStartY = 0; - private int imageSourceWidth = 0; - private int imageSourceHeight = 0; - - /** Processed LOD pyramid (Option B: process L0 then downsample processed). */ - private List lodProcessedLevels = List.of(); - - /** Active LOD index into lodProcessedLevels. */ - private int activeLodIndex = -1; - - /** Active HeightField (alias of lodProcessedLevels.get(activeLodIndex)). */ - private HeightField activeHeightField = null; - - /** Constant world extents used to keep surface size stable across LOD changes. */ - private double baseWorldWidth = Double.NaN; - private double baseWorldDepth = Double.NaN; - - /** - * Image-backed surfaces use resolution-independent physical dimensions. The longer - * image axis occupies this many world units when surfScale == DEFAULT_SURFSCALE; - * the shorter axis is derived from the source aspect ratio. - */ - private boolean imageBackedSurface = false; - - /** - * True only while model state is being pushed into HypersurfaceControlsPane. - * GUI control listeners echo their changes back as HypersurfaceEvents, so those - * echoes must not be interpreted as user-requested geometry rebuilds. - */ - private boolean syncingGuiControls = false; - - private final double imageWorldExtentReferenceScale = Math.max(1.0e-6, DEFAULT_SURFSCALE); - private double nominalImageWorldExtent = DEFAULT_XWIDTH * (double) DEFAULT_SURFSCALE; - - /** Current mesh spacing in world units per cell for the active LOD. */ - private double currentLodSurfScaleX = Double.NaN; - private double currentLodSurfScaleZ = Double.NaN; - - /** Screen-space LOD scheduler/selector for the legacy/global renderer. */ - private LodManager lodManager = null; - - /** Tiled per-region renderer used for primitive image-backed heightfields. */ - private TiledSurfaceRenderer tiledSurfaceRenderer = null; - private boolean tiledHeightFieldRenderingEnabled = true; - private int tileCellsL0 = 256; - -// Event-driven state fields for settings previously set via controls - private HeightMode heightMode = HeightMode.RAW; - private boolean smoothingEnabled = false; - private SurfaceUtils.Smoothing smoothingMethod = SurfaceUtils.Smoothing.GAUSSIAN; - private int smoothingRadius = 2; - private double gaussianSigma = 1.0; - private SurfaceUtils.Interpolation interpMode = SurfaceUtils.Interpolation.NEAREST; - private boolean toneEnabled = false; - private SurfaceUtils.ToneMap toneOperator = SurfaceUtils.ToneMap.NONE; - private double toneParam = 2.0; - // --- Graph layer support --- - private final Group graphLayer = new Group(); // sits in sceneRoot - private boolean graphVisible = true; - private GraphDirectedCollection currentGraph = null; - private Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() - .withNodeRadius(20.0) - .withEdgeWidth(8.0f) - .withPositionScalar(1.0); - // Graph visual style state (synced with GraphStyleControlsView) - private GraphStyleParams styleParams = new GraphStyleParams(); - - public Hypersurface3DPane(Scene scene) { - this.scene = scene; - surfaceRowOrientation.addListener((obs, oldValue, newValue) -> { - applySurfaceRowOrientationToRenderers(); - if (!suppressRowOrientationRefresh && surfPlot != null) { - updateTheMesh(); - } - }); - surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { - applySurfaceHeightOrientationToRenderers(); - if (surfPlot != null) { - updateTheMesh(); - } - }); - shape3DToCalloutMap = new HashMap<>(); - ambientLight = new AmbientLight(Color.WHITE); - - setBackground(Background.EMPTY); - subScene = new SubScene(sceneRoot, sceneWidth, sceneHeight, true, SceneAntialiasing.BALANCED); - subScene.widthProperty().bind(widthProperty()); - subScene.heightProperty().bind(heightProperty()); - subScene.setFill(sceneColor); - - //add our nodes to the group that will later be added to the 3D scene - nodeGroup.getChildren().addAll(xSphere, ySphere, zSphere); - nodeGroup.getTransforms().addAll(rotateX, rotateY, rotateZ); - xSphere.setTranslateX(planeSize / 2.0); - xSphere.setMaterial(new PhongMaterial(Color.RED)); - ySphere.setTranslateY(-planeSize / 2.0); - ySphere.setMaterial(new PhongMaterial(Color.GREEN)); - zSphere.setTranslateZ(planeSize / 2.0); - zSphere.setMaterial(new PhongMaterial(Color.BLUE)); - highlightedPoint.setMaterial(new PhongMaterial(Color.ALICEBLUE)); - highlightedPoint.setDrawMode(DrawMode.FILL); - highlightedPoint.setMouseTransparent(true); - - // Labels - Font font = new Font("Consolas", 20); - xLabel.setTextFill(Color.YELLOW); - xLabel.setFont(font); - xLabel.setMouseTransparent(true); - yLabel.setTextFill(Color.SKYBLUE); - yLabel.setFont(font); - yLabel.setMouseTransparent(true); - zLabel.setTextFill(Color.LIGHTGREEN); - zLabel.setFont(font); - zLabel.setMouseTransparent(true); - - hoverText.setStroke(Color.ALICEBLUE); - hoverText.setStrokeWidth(2); - hoverText.setFill(Color.CYAN); - hoverText.setFont(new Font("Consolas", 30)); - hoverText.setMouseTransparent(true); - - labelGroup.getChildren().addAll(xLabel, yLabel, zLabel, hoverText); - labelGroup.setManaged(false); - shape3DToLabel.put(xSphere, xLabel); - shape3DToLabel.put(ySphere, yLabel); - shape3DToLabel.put(zSphere, zLabel); - shape3DToLabel.put(highlightedPoint, hoverText); - camera = new PerspectiveCamera(true); - - cameraTransform.setTranslate(0, 0, 0); - cameraTransform.getChildren().add(camera); - camera.setNearClip(0.1); - camera.setFarClip(100000.0); - camera.setTranslateZ(cameraDistance); - cameraTransform.ry.setAngle(-45.0); - cameraTransform.rx.setAngle(-10.0); - setupSkyBox(); - debugGroup.setVisible(false); - extrasGroup.setVisible(false); - labelGroup.setVisible(false); - sceneRoot.getChildren().addAll(cameraTransform, highlightedPoint, - nodeGroup, extrasGroup, debugGroup, dataXForm); - // Add graph layer last so it draws above the surface (z-order within Group) - sceneRoot.getChildren().add(graphLayer); - graphLayer.setVisible(graphVisible); - // Sync controls with current visibility on startup - fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); - subScene.setCamera(camera); - pointLight = new PointLight(Color.WHITE); - cameraTransform.getChildren().add(pointLight); - pointLight.setTranslateX(camera.getTranslateX()); - pointLight.setTranslateY(camera.getTranslateY()); - pointLight.setTranslateZ(camera.getTranslateZ() + 500.0); - - subScene.setOnZoom(event -> { - double modifier = 50.0; - double modifierFactor = 0.1; - double z = camera.getTranslateZ(); - double newZ = z + event.getZoomFactor() * modifierFactor * modifier; - camera.setTranslateZ(newZ); - updateLabels(); - requestLodUpdate(LodManager.UpdateReason.SCROLL); -}); - - subScene.setOnKeyPressed(event -> { - KeyCode keycode = event.getCode(); - - if ((keycode == KeyCode.NUMPAD0 && event.isControlDown()) - || (keycode == KeyCode.DIGIT0 && event.isControlDown())) { - resetView(1000, false); - } else if ((keycode == KeyCode.NUMPAD0 && event.isShiftDown()) - || (keycode == KeyCode.DIGIT0 && event.isShiftDown())) { - resetView(0, true); - } - double change = 10.0; - if (event.isShiftDown()) change = 100.0; - - if (keycode == KeyCode.W) camera.setTranslateZ(camera.getTranslateZ() + change); - if (keycode == KeyCode.S) camera.setTranslateZ(camera.getTranslateZ() - change); - if (keycode == KeyCode.PLUS && event.isShortcutDown()) camera.setTranslateZ(camera.getTranslateZ() + change); - if (keycode == KeyCode.MINUS && event.isShortcutDown()) camera.setTranslateZ(camera.getTranslateZ() - change); - - if (keycode == KeyCode.A) camera.setTranslateX(camera.getTranslateX() - change); - if (keycode == KeyCode.D) camera.setTranslateX(camera.getTranslateX() + change); - if (keycode == KeyCode.SPACE) camera.setTranslateY(camera.getTranslateY() + change); - if (keycode == KeyCode.X) camera.setTranslateY(camera.getTranslateY() - change); - - change = event.isShiftDown() ? 10.0 : 1.0; - if (keycode == KeyCode.NUMPAD7 || (keycode == KeyCode.DIGIT8)) cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() + change); - if (keycode == KeyCode.NUMPAD9 || (keycode == KeyCode.DIGIT8 && event.isControlDown())) - cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() - change); - if (keycode == KeyCode.NUMPAD4 || (keycode == KeyCode.DIGIT9)) cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() + change); - if (keycode == KeyCode.NUMPAD6 || (keycode == KeyCode.DIGIT9 && event.isControlDown())) - cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - change); - if (keycode == KeyCode.NUMPAD1 || (keycode == KeyCode.DIGIT0)) cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + change); - if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) - cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() - change); - - if (keycode == KeyCode.COMMA) { - if (xFactorIndex > 0 && yFactorIndex > 0 && zFactorIndex > 0) { - xFactorIndex -= 1; - yFactorIndex -= 1; - zFactorIndex -= 1; - Platform.runLater(() -> scene.getRoot().fireEvent( - new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, - new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = true; - if (redraw) { - updateView(false); - notifyIndexChange(); - } - updateLabels(); - } - } - if (keycode == KeyCode.PERIOD) { - int featureSize = featureVectors.isEmpty() ? factorMaxIndex : featureVectors.get(0).getData().size(); - if (xFactorIndex < factorMaxIndex - 1 && yFactorIndex < factorMaxIndex - 1 - && zFactorIndex < factorMaxIndex - 1 && xFactorIndex < featureSize - 1 - && yFactorIndex < featureSize - 1 && zFactorIndex < featureSize - 1) { - xFactorIndex += 1; - yFactorIndex += 1; - zFactorIndex += 1; - Platform.runLater(() -> scene.getRoot().fireEvent( - new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, - new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = true; - if (redraw) { - updateView(false); - notifyIndexChange(); - } - updateLabels(); - } else { - scene.getRoot().fireEvent(new CommandTerminalEvent("Feature Index Max Reached: (" - + featureSize + ")", new Font("Consolas", 20), Color.YELLOW)); - } - } - if (keycode == KeyCode.SLASH && event.isControlDown()) debugGroup.setVisible(!debugGroup.isVisible()); - if (keycode == KeyCode.Y) surfPlot.scaleHeight(1.1f); - if (keycode == KeyCode.H) surfPlot.scaleHeight(0.9f); - - if (keycode == KeyCode.I) { - double tz = event.isShiftDown() ? 50 : 5; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz); - } - if (keycode == KeyCode.K) { - double tz = event.isShiftDown() ? 50 : 5; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz); - } - - updateLabels(); - updateCalloutHeadPoints(subScene); - requestLodUpdate(LodManager.UpdateReason.OTHER); - }); - - subScene.setOnMousePressed((MouseEvent me) -> { - if (me.isSynthesized()) LOG.info("isSynthesized"); - mousePosX = me.getSceneX(); - mousePosY = me.getSceneY(); - mouseOldX = me.getSceneX(); - mouseOldY = me.getSceneY(); - }); - subScene.setOnZoom(e -> { - double zoom = e.getZoomFactor(); - if (zoom > 1) camera.setTranslateZ(camera.getTranslateZ() + 50.0); - else camera.setTranslateZ(camera.getTranslateZ() - 50.0); - updateLabels(); - updateCalloutHeadPoints(subScene); - requestLodUpdate(LodManager.UpdateReason.SCROLL); - e.consume(); - }); - subScene.setOnScroll((ScrollEvent event) -> { - double modifier = 50.0; - double modifierFactor = 0.1; - if (event.isControlDown()) modifier = 1; - if (event.isShiftDown()) modifier = 100.0; - double z = camera.getTranslateZ(); - double newZ = z + event.getDeltaY() * modifierFactor * modifier; - camera.setTranslateZ(newZ); - updateLabels(); - updateCalloutHeadPoints(subScene); - requestLodUpdate(LodManager.UpdateReason.SCROLL); -}); - - subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); - Pane pathPane = App.getAppPathPaneStack(); - surfaceChartPane = new SurfaceChartPane(scene, pathPane); - bp = new BorderPane(subScene); - getChildren().clear(); - getChildren().addAll(bp, labelGroup); - - - MenuItem showControlsItem = new MenuItem("Hypersurface Controls"); - showControlsItem.setOnAction(e -> { - scene.getRoot().fireEvent(new ApplicationEvent( - ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE)); - }); - - - subScene.setOnMouseReleased((MouseEvent me) -> { - forceLodUpdate(); - }); -MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); - copyAsImageItem.setOnAction((ActionEvent e) -> { - Clipboard clipboard = Clipboard.getSystemClipboard(); - ClipboardContent content = new ClipboardContent(); - content.putImage(this.snapshot(new SnapshotParameters(), null)); - clipboard.setContent(content); - }); - MenuItem saveSnapshotItem = new MenuItem("Save Scene as Image"); - saveSnapshotItem.setOnAction((ActionEvent e) -> { - final FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Save scene as..."); - fileChooser.setInitialFileName("trinity_hypersurface.png"); - fileChooser.setInitialDirectory(Paths.get(".").toFile()); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG", "*.png")); - File file = fileChooser.showSaveDialog(null); - if (file != null) { - WritableImage image = this.snapshot(new SnapshotParameters(), null); - try { - ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); - } catch (IOException ioe) { - } - } - }); - MenuItem unrollHyperspaceItem = new MenuItem("Unroll Hyperspace Data"); - unrollHyperspaceItem.setOnAction(e -> unrollHyperspace()); - - MenuItem vectorDistanceItem = new MenuItem("Show Vector Distances"); - vectorDistanceItem.setOnAction(e -> computeVectorDistances()); - - MenuItem collectionDifferenceItem = new MenuItem("Feature Collection Difference"); - collectionDifferenceItem.setOnAction(e -> { - final FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Load FeatureCollection to Compare..."); - fileChooser.setInitialDirectory(Paths.get(".").toFile()); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); - File file = fileChooser.showOpenDialog(null); - if (file != null) { - FeatureCollectionFile fcf; - try { - fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); - computeSurfaceDifference(fcf.featureCollection); - } catch (IOException ex) { - LOG.error(null, ex); - } - } - }); - MenuItem cosineSimilarityItem = new MenuItem("Feature Collection Cosine Distance"); - cosineSimilarityItem.setOnAction(e -> { - final FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle("Load FeatureCollection to Compare..."); - fileChooser.setInitialDirectory(Paths.get(".").toFile()); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); - File file = fileChooser.showOpenDialog(null); - if (file != null) { - FeatureCollectionFile fcf; - try { - fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); - computeCosineDistance(fcf.featureCollection); - } catch (IOException ex) { - LOG.error(null, ex); - } - } - }); - - Glow glow = new Glow(0.5); - ImageView analysisImageView = ResourceUtils.loadIcon("analysis", ICON_FIT_HEIGHT); - analysisImageView.setEffect(glow); - Menu analysisMenu = new Menu("Analysis", analysisImageView, - vectorDistanceItem, collectionDifferenceItem, cosineSimilarityItem - ); - - CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); - enableHoverItem.setOnAction(e -> { - hoverInteractionsEnabled = enableHoverItem.isSelected(); - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); - } - }); - - CheckMenuItem surfaceChartsItem = new CheckMenuItem("Surface Charts"); - surfaceChartsItem.setOnAction(e -> { - surfaceChartsEnabled = surfaceChartsItem.isSelected(); - if (surfaceChartsEnabled) { - Pane pp = App.getAppPathPaneStack(); - if (null == surfaceChartPane) { - surfaceChartPane = new SurfaceChartPane(scene, pp); - surfaceChartPane.visibleProperty().bind(this.visibleProperty()); - } - if (!pp.getChildren().contains(surfaceChartPane)) { - pp.getChildren().add(surfaceChartPane); - surfaceChartPane.slideInPane(); - } else { - surfaceChartPane.show(); - } - } - }); - - MenuItem updateAllItem = new MenuItem("Update Render"); - updateAllItem.setOnAction(e -> updateAll()); - MenuItem clearDataItem = new MenuItem("Clear Data"); - clearDataItem.setOnAction(e -> { - clearAll(); - xWidth = DEFAULT_XWIDTH; - zWidth = DEFAULT_ZWIDTH; - syncGuiControls(); - generateRandos(xWidth, zWidth, yScale); - captureDataGridAsSource(); - rebuildProcessedGridAndRefresh(); - }); - - CheckMenuItem showDataMarkersItem = new CheckMenuItem("Show Data Markers"); - showDataMarkersItem.setOnAction(e -> { - extrasGroup.setVisible(showDataMarkersItem.isSelected()); - labelGroup.setVisible(showDataMarkersItem.isSelected()); - if (null != anchorCallout) anchorCallout.setVisible(showDataMarkersItem.isSelected()); - }); - - CheckMenuItem enableCrosshairsItem = new CheckMenuItem("Enable Crosshairs"); - enableCrosshairsItem.setOnAction(e -> crosshairsEnabled = enableCrosshairsItem.isSelected()); - - MenuItem resetViewItem = new MenuItem("Reset View"); - resetViewItem.setOnAction(e -> resetView(1000, false)); - ContextMenu cm = new ContextMenu(showControlsItem, - copyAsImageItem, saveSnapshotItem, unrollHyperspaceItem, analysisMenu, - enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, - updateAllItem, clearDataItem, resetViewItem); - cm.setAutoFix(true); - cm.setAutoHide(true); - cm.setHideOnEscape(true); - cm.setOpacity(0.85); - - subScene.setOnMouseClicked((MouseEvent e) -> { - if (e.getButton() == MouseButton.SECONDARY) { - if (!cm.isShowing()) cm.show(this.getParent(), e.getScreenX(), e.getScreenY()); - else cm.hide(); - e.consume(); - } - }); -// Style params changed from GraphStyleControlsView - this.scene.addEventHandler(GraphEvent.GRAPH_STYLE_PARAMS_CHANGED, e -> { - GraphStyleParams p = (GraphStyleParams) e.object; - if (p == null) return; - - // Update local style state - styleParams.nodeColor = p.nodeColor; - styleParams.nodeRadius = p.nodeRadius; - styleParams.nodeOpacity = clamp01(p.nodeOpacity); - styleParams.edgeColor = p.edgeColor; - styleParams.edgeWidth = p.edgeWidth; - styleParams.edgeOpacity = clamp01(p.edgeOpacity); - - // Apply style. Rebuild graph only if edge width changed. - applyGraphStyle(styleParams, /*rebuildIfNeeded*/ true); - }); - - // Reset style defaults - this.scene.addEventHandler(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, e -> { - styleParams = new GraphStyleParams(); // back to defaults - - // Keep renderer params consistent for rebuilds - graphParams.withNodeRadius(styleParams.nodeRadius) - .withEdgeWidth((float) styleParams.edgeWidth); - - // Rebuild (edge width) then apply everything else live - if (currentGraph != null) { - graphLayer.getChildren().setAll( - Graph3DRenderer.buildGraphGroup(currentGraph, graphParams) - ); - } - applyGraphStyle(styleParams, /*rebuildIfNeeded*/ false); - - // GUI-sync so controls show defaults - fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); - }); - - this.scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { - if (!(e.object instanceof GraphDirectedCollection gc)) return; - currentGraph = gc; - graphLayer.getChildren().clear(); - graphLayer.getChildren().add(Graph3DRenderer.buildGraphGroup(gc, graphParams)); - - // Apply current style to the freshly built graph - applyGraphStyle(styleParams, /*rebuildIfNeeded*/ false); - - // GUI-sync so pickers/sliders reflect the active style - fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); - fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); - scene.getRoot().fireEvent(new CommandTerminalEvent( - "Rendered 3D graph: nodes=" + gc.getNodes().size() + ", edges=" + gc.getEdges().size(), - new Font("Consolas", 18), Color.LIGHTGREEN)); - }); - - loadSurf3D(); - this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { - Color color = (Color) e.object; - subScene.setFill(color); - }); - this.scene.addEventHandler(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, e -> { - skybox.setVisible((Boolean) e.object); - }); - this.scene.addEventHandler(ImageEvent.NEW_TEXTURE_SURFACE, e -> { - Image image = (Image) e.object; - int x1 = 0; - int y1 = 0; - int x2 = (int) image.getWidth(); - int y2 = (int) image.getHeight(); - if (x2 > 512 || y2 > 512) { - boolean split = false; - Alert alert = new Alert(Alert.AlertType.CONFIRMATION, - "Image has " + x2 + " rows and " + y2 + " columns.\n" - + "Split the image before tessellation?", - ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); - alert.setTitle("Image Tessellation Import"); - alert.setHeaderText("Image has " + x2 + " rows and " + y2 + " columns.\n"); - alert.setContentText("Select subregion from image before tessellation?"); - alert.setGraphic(ResourceUtils.loadIcon("alert", 75)); - alert.initStyle(StageStyle.TRANSPARENT); - DialogPane dialogPane = alert.getDialogPane(); - dialogPane.setBackground(Background.EMPTY); - dialogPane.getScene().setFill(Color.TRANSPARENT); - Optional optBT = alert.showAndWait(); - if (optBT.get().equals(ButtonType.CANCEL)) return; - split = optBT.get().equals(ButtonType.YES); - if (split) { - scene.getRoot().fireEvent(new ApplicationEvent( - ApplicationEvent.SHOW_PIXEL_SELECTION, image)); - return; - } - } - tessellateImage(image, x1, y1, x2, y2); - lastImage = image; - lastImageSource = image.getUrl(); - }); - this.scene.addEventHandler(HyperspaceEvent.FACTOR_COORDINATES_GUI, e -> { - CoordinateSet coords = (CoordinateSet) e.object; - xFactorIndex = coords.coordinateIndices.get(0); - yFactorIndex = coords.coordinateIndices.get(1); - zFactorIndex = coords.coordinateIndices.get(2); - updateLabels(); - updateView(true); - notifyIndexChange(); - }); - - scene.addEventHandler(HyperspaceEvent.FACTOR_VECTORMAX_GUI, e -> { - int newFactorMaxIndex = (int) e.object; - if (newFactorMaxIndex < factorMaxIndex) { - factorMaxIndex = newFactorMaxIndex; - boolean update = false; - if (xFactorIndex > factorMaxIndex) { - xFactorIndex = factorMaxIndex; - update = true; - } - if (yFactorIndex > factorMaxIndex) { - yFactorIndex = factorMaxIndex; - update = true; - } - if (zFactorIndex > factorMaxIndex) { - zFactorIndex = factorMaxIndex; - update = true; - } - if (update) { - updateView(true); - notifyIndexChange(); - } - } else factorMaxIndex = newFactorMaxIndex; - }); - - scene.addEventHandler(HypersurfaceGridEvent.RENDER_PDF, e -> { - applySurfaceGridToHypersurface(e.getZGrid()); - e.consume(); - }); - scene.addEventHandler(HypersurfaceGridEvent.RENDER_CDF, e -> { - applySurfaceGridToHypersurface(e.getZGrid()); - e.consume(); - }); - - scene.addEventHandler(HyperspaceEvent.NODE_QUEUELIMIT_GUI, e -> queueLimit = (int) e.object); - scene.addEventHandler(HyperspaceEvent.REFRESH_RATE_GUI, e -> hypersurfaceRefreshRate = (long) e.object); - - scene.addEventHandler(ShadowEvent.SHOW_AXES_LABELS, e -> { - nodeGroup.setVisible((boolean) e.object); - labelGroup.setVisible((boolean) e.object); - }); - scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, e -> imageryBasePath = (String) e.object); - Platform.runLater(() -> { - updateLabels(); - updateView(true); - updateTheMesh(); - }); - AnimationTimer surfUpdateAnimationTimer = new AnimationTimer() { - long sleepNs = 0; - long prevTime = 0; - long NANOS_IN_MILLI = 1_000_000; - - @Override - public void handle(long now) { - sleepNs = hypersurfaceRefreshRate * NANOS_IN_MILLI; - if ((now - prevTime) < sleepNs) return; - prevTime = now; - long startTime; - if (computeRandos) { - generateRandos(xWidth, zWidth, yScale); - captureDataGridAsSource(); - rebuildProcessedGridAndRefresh(); - } else if (animated || isDirty) { - startTime = System.nanoTime(); - updateTheMesh(); - LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); - } - } - }; - surfUpdateAnimationTimer.start(); - } - - /** - * Apply style to current graph. Rebuild only if edge-width changed and requested. - */ - private void applyGraphStyle(GraphStyleParams p, boolean rebuildIfNeeded) { - if (p == null) return; - - // Determine if edge width differs from the built state - double currentEdgeWidth = graphParams.edgeWidth; // float in params, promoted to double - boolean needRebuild = Math.abs(p.edgeWidth - currentEdgeWidth) > 1e-6; - - if (needRebuild && rebuildIfNeeded && currentGraph != null) { - // Update params and rebuild to reflect edge width + node radius - graphParams.withNodeRadius(p.nodeRadius) - .withEdgeWidth((float) p.edgeWidth); - graphLayer.getChildren().setAll( - Graph3DRenderer.buildGraphGroup(currentGraph, graphParams) - ); - } - - // Apply color/opacity/radius live to existing nodes/edges - for (Node n : graphLayer.getChildren()) { - applyGraphStyleRecursive(n, p); - } - } - - private void applyGraphStyleRecursive(Node n, GraphStyleParams p) { - if (n instanceof AnimatedSphere s) { - // color - if (p.nodeColor != null) { - s.setColor(new Color( - p.nodeColor.getRed(), - p.nodeColor.getGreen(), - p.nodeColor.getBlue(), - // keep whatever alpha the sphere currently has; set below - s.getPhongMaterial().getDiffuseColor() != null - ? s.getPhongMaterial().getDiffuseColor().getOpacity() - : 1.0 - )); - } - // radius - s.setSphereRadius(p.nodeRadius); - // opacity via material alpha - s.setMaterialOpacity(p.nodeOpacity); - - } else if (n instanceof Tracer t) { - // color - if (p.edgeColor != null) { - t.setDiffuseColor(new Color( - p.edgeColor.getRed(), - p.edgeColor.getGreen(), - p.edgeColor.getBlue(), - // keep current alpha; set below - 1.0 - )); - } - // opacity via material alpha - t.setOpacityAlpha(p.edgeOpacity); - - } else if (n instanceof Parent parent) { - for (Node c : parent.getChildrenUnmodifiable()) { - applyGraphStyleRecursive(c, p); - } - } - } - - - private static double clamp01(double v) { - return Math.max(0.0, Math.min(1.0, v)); - } - - public void computeCosineDistance(FeatureCollection collection) { - double[][] newRayRay = collection.convertFeaturesToArray(); - Metric metric = Metric.getMetric("cosine"); - List cosineDistancesGrid = new ArrayList<>(); - for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { - double[] dataGridVector = dataGrid.get(rowIndex).stream().mapToDouble(Double::doubleValue).toArray(); - double currentDistance = metric.distance(dataGridVector, newRayRay[rowIndex]); - cosineDistancesGrid.add(currentDistance); - } - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", - cosineDistancesGrid.toArray(Double[]::new))); - LOG.info("{}", cosineDistancesGrid); - } - - private void applySurfaceGridToHypersurface(List> grid) { - double userScale = 1.0; // future: user control - List> scaled = DataUtils.normalizeAndScale(grid, heightMode, userScale); - dataGrid.clear(); - dataGrid.addAll(scaled); - captureDataGridAsSource(); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); - syncGuiControls(); - rebuildProcessedGridAndRefresh(); // NEW: run pipeline - } - - public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean flipY) { - List> grid = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); - if (flipY) Collections.reverse(grid); - dataGrid.clear(); - dataGrid.addAll(grid); - captureDataGridAsSource(); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); - syncGuiControls(); - rebuildProcessedGridAndRefresh(); // NEW - } - - public void computeSurfaceDifference(FeatureCollection collection) { - double[][] newRayRay = collection.convertFeaturesToArray(); - List> differencesGrid = new ArrayList<>(); - for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { - List differenceVector = new ArrayList<>(); - List currentRow = dataGrid.get(rowIndex); - int width = currentRow.size(); - for (int colIndex = 0; colIndex < width; colIndex++) { - if (rowIndex < newRayRay.length && colIndex < newRayRay[rowIndex].length) { - differenceVector.add(currentRow.get(colIndex) - newRayRay[rowIndex][colIndex]); - } else differenceVector.add(0.0); - } - differencesGrid.add(differenceVector); - } - dataGrid.clear(); - dataGrid.addAll(differencesGrid); - captureDataGridAsSource(); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); - syncGuiControls(); - rebuildProcessedGridAndRefresh(); // NEW - } - - public void computeVectorDistances() { - Metric metric = Metric.getMetric("cosine"); - List> distancesGrid = new ArrayList<>(); - dataGrid.stream().forEach(row -> { - double[] rowVector = row.stream().mapToDouble(Double::doubleValue).toArray(); - List distanceVector = new ArrayList<>(); - for (int i = 0; i < dataGrid.size(); i++) { - double[] xVector = dataGrid.get(i).stream().mapToDouble(Double::doubleValue).toArray(); - double currentDistance = metric.distance(xVector, rowVector); - distanceVector.add(currentDistance); - } - distancesGrid.add(distanceVector); - }); - dataGrid.clear(); - dataGrid.addAll(distancesGrid); - captureDataGridAsSource(); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); - syncGuiControls(); - rebuildProcessedGridAndRefresh(); // NEW - } - - public void unrollHyperspace() { - getScene().getRoot().fireEvent(new CommandTerminalEvent("Requesting Hyperspace Vectors...", - new Font("Consolas", 20), Color.GREEN)); - getScene().getRoot().fireEvent(new FeatureVectorEvent(FeatureVectorEvent.REQUEST_FEATURE_COLLECTION)); - } - - public void updateCalloutHeadPoint(Shape3D node, Callout callout, SubScene subScene) { - Point2D p2d = JavaFX3DUtils.getTransformedP2D(node, subScene, callout.head.getRadius() + 5); - callout.updateHeadPoint(p2d.getX(), p2d.getY()); - } - - public void updateCalloutHeadPoints(SubScene subScene) { - shape3DToCalloutMap.forEach((node, callout) -> updateCalloutHeadPoint(node, callout, subScene)); - } - - public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubScene subScene) { - ImageView iv = loadImageView(featureVector, featureVector.isBBoxValid()); - iv.setPreserveRatio(true); - iv.setFitWidth(CHIP_FIT_WIDTH); - iv.setFitHeight(CHIP_FIT_WIDTH); - TitledPane imageTP = new TitledPane(); - imageTP.setContent(iv); - imageTP.setText("Imagery"); - Point2D p2D = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, Callout.DEFAULT_HEAD_RADIUS + 5); - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : featureVector.getMetaData().entrySet()) - sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - Text metaText = new Text(sb.toString()); - TitledPane metaTP = new TitledPane(); - metaTP.setContent(metaText); - metaTP.setText("Metadata"); - Callout infoCallout = CalloutBuilder.create() - .headPoint(p2D.getX(), p2D.getY()) - .leaderLineToPoint(p2D.getX() - 100, p2D.getY() - 150) - .endLeaderLineRight() - .mainTitle(featureVector.getLabel(), new VBox(3, imageTP, metaTP)) - .subTitle(featureVector.getEntityId()) - .pause(10) - .build(); - infoCallout.setPickOnBounds(false); - infoCallout.setManaged(false); - addCallout(infoCallout, shape3D); - infoCallout.play().setOnFinished(eh -> { - if (null == featureVector.getImageURL() || featureVector.getImageURL().isBlank()) imageTP.setExpanded(false); - }); - return infoCallout; - } - - public void addCallout(Callout callout, Shape3D shape3D) { - callout.setManaged(false); - getChildren().add(callout); - shape3DToCalloutMap.put(shape3D, callout); - } - - public void updateTheMesh() { - if (surfPlot == null) return; - - sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); - - final boolean useTiledRenderer = surfaceRender && shouldUseTiledHeightFieldRenderer(); - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.setVisible(useTiledRenderer); - } - if (useTiledRenderer) { - surfPlot.setVisible(false); - if (paintMeshView != null) paintMeshView.setVisible(false); - configureTiledAppearance(); - tiledSurfaceRenderer.forceUpdate(); - return; - } - - // An image-backed surface with no active HeightField is between source - // replacement and LOD-pyramid activation (or is waiting for the global LOD - // selector). Never fall through to the legacy raw FXyz mesh builder here: - // xWidth/zWidth may still describe the full 4K/8K source and attempting a - // raw mesh at that resolution can exhaust the Java heap. - if (imageBackedSurface && activeHeightField == null) { - surfPlot.setVisible(false); - if (paintMeshView != null) paintMeshView.setVisible(false); - return; - } - - surfPlot.setVisible(surfaceRender); - if (paintMeshView != null) paintMeshView.setVisible(true); - - final int renderWidth = getRenderWidth(); - final int renderHeight = getRenderHeight(); - final double renderScaleX = getRenderScaleX(); - final double renderScaleZ = getRenderScaleZ(); - - if (surfaceRender) { - surfPlot.setRowOrientation(getSurfaceRowOrientation()); - surfPlot.setHeightOrientation(getSurfaceHeightOrientation()); - // Primitive HeightField meshes bypass the FXyz Point3D/Face3 object graph. - // Shapley coloration temporarily retains the legacy path until its scalar - // field is made LOD-aware; image and height coloration use the fast path. - boolean useDirectHeightFieldMesh = activeHeightField != null - && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; - - long meshStart = System.nanoTime(); - if (useDirectHeightFieldMesh) { - surfPlot.updateMeshHeightField(activeHeightField, - renderScaleX, yScale, renderScaleZ); - System.out.println("Direct HeightField mesh built: " - + renderWidth + "x" + renderHeight + " in " - + Utils.totalTimeString(meshStart)); - } else { - surfPlot.updateMeshRaw(renderWidth, renderHeight, - renderScaleX, yScale, renderScaleZ); - } - applyCurrentColoration(); - } else { - // Cylinder/tube mode still needs the legacy boxed representation, but it must - // use the bounded active render LOD rather than the full-resolution source. - List> renderGrid = activeHeightField != null - ? SurfaceUtils.toGrid(activeHeightField) - : dataGrid; - TessellationTube tube = new TessellationTube(renderGrid, Color.WHITE, - yScale * 10, renderScaleZ, yScale); - tube.setMouseTransparent(true); - if (null != lastImage) { - tube.meshView.setDrawMode(DrawMode.FILL); - tube.colorByImage = colorationMethod == COLORATION.COLOR_BY_IMAGE; - tube.updateMaterial(lastImage); - } - Platform.runLater(() -> sceneRoot.getChildren().add(tube)); - } - Platform.runLater(this::updatePaintMesh); - } - - /** - * Apply the currently selected surface coloration without rebuilding geometry. - * Image coloration uses the already-loaded JavaFX Image directly so it works for - * absolute file URLs as well as images that did not originate under imageryBasePath. - */ - private void applyCurrentColoration() { - if (surfPlot == null || colorationMethod == null) return; - if (shouldUseTiledHeightFieldRenderer()) { - configureTiledAppearance(); - return; - } - - switch (colorationMethod) { - case COLOR_BY_IMAGE -> { - if (lastImage == null) return; - if (surfPlot.isDirectHeightFieldMesh()) { - surfPlot.setDirectTextureModeImage(lastImage); - } else { - PhongMaterial material; - if (surfPlot.getMaterial() instanceof PhongMaterial existing) { - material = existing; - } else { - material = new PhongMaterial(Color.WHITE); - } - material.setDiffuseColor(Color.WHITE); - material.setDiffuseMap(lastImage); - surfPlot.setMaterial(material); - } - } - case COLOR_BY_FEATURE -> { - if (surfPlot.isDirectHeightFieldMesh()) { - // Use one stable data range for the entire LOD pyramid so colors do - // not shift as the camera moves between L0/L1/L2. Image-backed - // HeightFields are typically around 0..1 before Y scaling, so the - // previous hard-coded 0..360 range collapsed almost all values into - // the red end of the palette. - HeightField colorRangeField = !lodProcessedLevels.isEmpty() - ? lodProcessedLevels.get(0) - : activeHeightField; - if (colorRangeField != null) { - float[] minMax = colorRangeField.minMax(); - double minColorValue = minMax[0] * yScale; - double maxColorValue = minMax[1] * yScale; - if (!(maxColorValue > minColorValue)) { - double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); - minColorValue -= pad; - maxColorValue += pad; - } - surfPlot.setDirectTextureModeByHeight( - TOTAL_COLORS, minColorValue, maxColorValue); - } - } else { - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); - } - } - case COLOR_BY_SHAPLEY -> - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); - } - } - - public void updatePaintMesh() { - if (surfPlot == null || surfPlot.getMesh() == null) return; - final int renderWidth = getRenderWidth(); - final int renderHeight = getRenderHeight(); - diffusePaintImage = new WritableImage(renderWidth, renderHeight); - if (null == paintTriangleMesh) { - paintTriangleMesh = new TriangleMesh(); - paintMeshView = new MeshView(paintTriangleMesh); - paintMeshView.setMouseTransparent(true); - paintMeshView.setMesh(paintTriangleMesh); - paintMeshView.setCullFace(CullFace.NONE); - paintPhong = new PhongMaterial(Color.WHITE, diffusePaintImage, null, null, null); - paintPhong.setSpecularColor(Color.WHITE); - paintPhong.setDiffuseColor(Color.WHITE); - paintMeshView.setMaterial(paintPhong); - sceneRoot.getChildren().add(paintMeshView); - surfPlot.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { - if (e.getClickCount() > 1 && e.isControlDown()) { - Color color = new Color(rando.nextDouble(), rando.nextDouble(), rando.nextDouble(), 1.0); - paintSingleColor(color); - } - }); - } - - TriangleMesh surfMesh = (TriangleMesh) surfPlot.getMesh(); - paintTriangleMesh.getPoints().setAll(surfMesh.getPoints()); - paintTriangleMesh.getFaces().clear(); - paintTriangleMesh.getTexCoords().clear(); - - final int texCoordSize = 2; - int pskip = 2; - int subDivX = (int) diffusePaintImage.getWidth() / pskip; - int subDivZ = (int) diffusePaintImage.getHeight() / pskip; - int numDivX = subDivX + 1; - int numVerts = (subDivZ + 1) * numDivX; - float currZ, currX; - float[] texCoords = new float[numVerts * texCoordSize]; - int faceCount = subDivX * subDivZ * 2; - final int faceSize = 6; - int[] faces = new int[faceCount * faceSize]; - int index, p00, p01, p10, p11, tc00, tc01, tc10, tc11; - - for (int z = 0; z < subDivZ; z++) { - currZ = (float) z / subDivZ; - for (int x = 0; x < subDivX; x++) { - currX = (float) x / subDivX; - index = z * numDivX * texCoordSize + (x * texCoordSize); - texCoords[index] = currX; - texCoords[index + 1] = currZ; - - p00 = z * numDivX + x; - p01 = p00 + 1; - p10 = p00 + numDivX; - p11 = p10 + 1; - tc00 = z * numDivX + x; - tc01 = tc00 + 1; - tc10 = tc00 + numDivX; - tc11 = tc10 + 1; - - index = (z * subDivX * faceSize + (x * faceSize)) * 2; - faces[index + 0] = p00; - faces[index + 1] = tc00; - faces[index + 2] = p10; - faces[index + 3] = tc10; - faces[index + 4] = p11; - faces[index + 5] = tc11; - index += faceSize; - faces[index + 0] = p11; - faces[index + 1] = tc11; - faces[index + 2] = p01; - faces[index + 3] = tc01; - faces[index + 4] = p00; - faces[index + 5] = tc00; - diffusePaintImage.getPixelWriter().setColor(x, z, Color.TRANSPARENT); - } - } - paintTriangleMesh.getTexCoords().setAll(texCoords); - paintTriangleMesh.getFaces().setAll(faces); - paintPhong.setDiffuseMap(diffusePaintImage); - paintMeshView.setTranslateZ(-1); - paintMeshView.setTranslateX(-getWorldWidth() / 2.0); - paintMeshView.setTranslateZ(-getWorldDepth() / 2.0); - } - - public void paintSingleColor(Color color) { - for (int z = 0; z < diffusePaintImage.getHeight(); z++) { - for (int x = 0; x < diffusePaintImage.getWidth(); x++) { - diffusePaintImage.getPixelWriter().setColor(x, z, color); - } - } - } - - public void illuminateCrosshair(Point3D center) { - if (null == diffusePaintImage) return; - int x = (int) Math.floor(center.getX() / Math.max(1e-9, getRenderScaleX())); - int z = (int) Math.floor(center.getZ() / Math.max(1e-9, getRenderScaleZ())); - x = Math.max(0, Math.min(x, (int) diffusePaintImage.getWidth() - 1)); - z = Math.max(0, Math.min(z, (int) diffusePaintImage.getHeight() - 1)); - PixelWriter pw = diffusePaintImage.getPixelWriter(); - for (int i = 0; i < diffusePaintImage.getWidth(); i++) pw.setColor(i, z, Color.WHITE); - for (int i = 0; i < diffusePaintImage.getHeight(); i++) pw.setColor(x, i, Color.WHITE); - } - - private void setupSkyBox() { - Image top = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); - Image bottom = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); - Image left = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); - Image right = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); - Image front = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); - Image back = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); - double size = 100000D; - skybox = new Skybox(top, bottom, left, right, front, back, size, camera); - sceneRoot.getChildren().add(skybox); - ambientLight.getScope().addAll(skybox); - skybox.setVisible(false); - } - - private void notifyIndexChange() { - getScene().getRoot().fireEvent(new CommandTerminalEvent("X,Y,Z Indices = (" - + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", - new Font("Consolas", 20), Color.GREEN)); - } - - public void resetView(double milliseconds, boolean rightNow) { - if (!rightNow) { - Timeline timeline = JavaFX3DUtils.transitionCameraTo(milliseconds, camera, cameraTransform, - 0, 0, cameraDistance, -10.0, -45.0, 0.0); - timeline.play(); - } else { - dataXForm.reset(); - } - } - - public void intro(double milliseconds) { - camera.setTranslateZ(DEFAULT_INTRO_DISTANCE); - JavaFX3DUtils.zoomTransition(milliseconds, camera, cameraDistance); - } - - public void outtro(double milliseconds) { - JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); - } - - public void updateAll() { - Platform.runLater(() -> updateView(true)); - } - - private void mouseDragCamera(MouseEvent me) { - mouseOldX = mousePosX; - mouseOldY = mousePosY; - mousePosX = me.getSceneX(); - mousePosY = me.getSceneY(); - mouseDeltaX = (mousePosX - mouseOldX); - mouseDeltaY = (mousePosY - mouseOldY); - double modifier = 1.0; - double modifierFactor = 0.1; - if (me.isControlDown()) modifier = 0.1; - if (me.isShiftDown()) modifier = 25.0; - if (me.isPrimaryButtonDown()) { - if (me.isAltDown()) - cameraTransform.rz.setAngle(((cameraTransform.rz.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); - else { - cameraTransform.ry.setAngle(((cameraTransform.ry.getAngle() + mouseDeltaX * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); - cameraTransform.rx.setAngle(((cameraTransform.rx.getAngle() - mouseDeltaY * modifierFactor * modifier * 2.0) % 360 + 540) % 360 - 180); - } - } else if (me.isMiddleButtonDown()) { - cameraTransform.t.setX(cameraTransform.t.getX() + mouseDeltaX * modifierFactor * modifier * 0.3); - cameraTransform.t.setY(cameraTransform.t.getY() + mouseDeltaY * modifierFactor * modifier * 0.3); - } - updateLabels(); - updateCalloutHeadPoints(subScene); - requestLodUpdate(LodManager.UpdateReason.DRAG); - } - - private void updateLabels() { - shape3DToLabel.forEach((shape3D, node) -> { - Point2D p2Ditty = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, 5); - double x = p2Ditty.getX(); - double y = p2Ditty.getY() - 25; - node.getTransforms().setAll(new Translate(x, y)); - }); - } - - private void handleSurfaceHover(Point3D surfacePoint) { - vertP3D = surfacePoint; - highlightedPoint.setTranslateX(vertP3D.x - getWorldWidth() / 2.0); - highlightedPoint.setTranslateY(vertP3D.y); - highlightedPoint.setTranslateZ(vertP3D.z - getWorldDepth() / 2.0); - updateCalloutHeadPoints(subScene); - updateLabels(); - - int row = Math.max(0, Math.min( - (int) Math.floor(vertP3D.getZ() / Math.max(1e-9, getRenderScaleZ())), - getRenderHeight() - 1)); - int column = Math.max(0, Math.min( - (int) Math.floor(vertP3D.getX() / Math.max(1e-9, getRenderScaleX())), - getRenderWidth() - 1)); - int orientedRenderRow = orientRowIndex(row, getRenderHeight()); - int sourceRow = mapRenderIndexToSource( - orientedRenderRow, getRenderHeight(), getSourceHeight()); - - if (anchorCallout != null) { - if (sourceRow < featureVectors.size()) { - updateCalloutByFeatureVector(anchorCallout, featureVectors.get(sourceRow)); - } - setSpheroidAnchor(false, sourceRow); - } - if (crosshairsEnabled && diffusePaintImage != null) { - paintSingleColor(Color.TRANSPARENT); - illuminateCrosshair(vertP3D); - } - if (surfaceChartsEnabled && activeHeightField != null) { - List xlist = getActiveRenderRow(row); - Double[] xRay = xlist.toArray(Double[]::new); - Double[] zRay = getActiveRenderColumn(column); - String text = "Coordinates: " + column + ", " + row + System.lineSeparator(); - text = text.concat("Value: ") - .concat(String.valueOf(getActiveRenderValue(row, column))) - .concat(System.lineSeparator()); - double maxX = xlist.stream().max(Double::compare).orElse(0.0); - text = text.concat("Max X: ").concat(String.valueOf(maxX)).concat(System.lineSeparator()); - double minX = xlist.stream().min(Double::compare).orElse(0.0); - text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); - double maxZ = Arrays.stream(zRay).max(Double::compare).orElse(0.0); - text = text.concat("Max Z: ").concat(String.valueOf(maxZ)).concat(System.lineSeparator()); - double minZ = Arrays.stream(zRay).min(Double::compare).orElse(0.0); - text = text.concat("Min Z: ").concat(String.valueOf(minZ)).concat(System.lineSeparator()); - hoverText.setText(text); - hoverText.setStrokeWidth(1); - hoverText.setLayoutX(50); - hoverText.setLayoutY(50); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, zRay)); - } - } - - private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { - ImageView iv = null; - try { - if (bboxOnly) { - WritableImage image = ResourceUtils.loadImageFileSubset(imageryBasePath + featureVector.getImageURL(), - featureVector.getBbox().get(0).intValue(), - featureVector.getBbox().get(1).intValue(), - featureVector.getBbox().get(2).intValue(), - featureVector.getBbox().get(3).intValue()); - iv = new ImageView(image); - } else if (null != featureVector.getImageURL() && !featureVector.getImageURL().isBlank()) { - iv = new ImageView(ResourceUtils.loadImageFile(imageryBasePath + featureVector.getImageURL())); - } else iv = new ImageView(ResourceUtils.loadIconFile("noimage")); - } catch (Exception ex) { - iv = new ImageView(ResourceUtils.loadIconFile("noimage")); - } - return iv; - } - - public void updateView(boolean forcePNodeUpdate) { - if (null != surfPlot) { - Platform.runLater(() -> { - if (heightChanged) { - heightChanged = false; - } - isDirty = false; - }); - } - } - - private void generateRandos(int xWidth, int zWidth, float yScale) { - if (null == dataGrid) dataGrid = new ArrayList<>(zWidth); - else dataGrid.clear(); - List xList; - for (int z = 0; z < zWidth; z++) { - xList = new ArrayList<>(xWidth); - for (int x = 0; x < xWidth; x++) xList.add(rando.nextDouble() * yScale); - dataGrid.add(xList); - } - } - - private static double frac(double v) { - v = v - Math.floor(v); - return (v < 0) ? v + 1.0 : v; - } - - private Number vertToHeight(Vert3D p) { - if (!surfaceRender) { - // cylinder path unchanged (uses dataGrid) - if (dataGrid == null) return 0.0; - return findBlerpHeight(p); - } - - // Prefer primitive active LOD field - if (activeHeightField == null) { - if (dataGrid == null) return 0.0; - switch (interpMode) { - case BILINEAR: - case BICUBIC: { - double gx = p.xIndex + frac(p.getX() / Math.max(1.0, surfScale)); - double gy = p.yIndex + frac(p.getY() / Math.max(1.0, surfScale)); - gy = orientRowCoordinate(gy, dataGrid.size()); - return SurfaceUtils.sample(dataGrid, gx, gy, interpMode); - } - case NEAREST: - default: - return lookupPoint(p); - } - } - - switch (interpMode) { - case BILINEAR: - case BICUBIC: { - double sx = Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; - double sz = Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; - double gx = p.xIndex + frac(p.getX() / Math.max(1.0, sx)); - double gy = p.yIndex + frac(p.getY() / Math.max(1.0, sz)); - gy = orientRowCoordinate(gy, activeHeightField.height()); - return SurfaceUtils.sample(activeHeightField, gx, gy, interpMode); - } - case NEAREST: - default: - return lookupPoint(p); - } - } - - private Number lookupPoint(Vert3D p) { - if (activeHeightField != null) { - int w = activeHeightField.width(); - int h = activeHeightField.height(); - if (p.yIndex < 0 || p.yIndex >= h || p.xIndex < 0 || p.xIndex >= w) return 0.0; - int dataRow = orientRowIndex(p.yIndex, h); - return (double) activeHeightField.data()[dataRow * w + p.xIndex]; - } - if (dataGrid == null) return 0.0; - if (p.yIndex < 0 || p.yIndex >= dataGrid.size() - || p.xIndex < 0 || p.xIndex >= dataGrid.get(0).size()) return 0.0; - int dataRow = orientRowIndex(p.yIndex, dataGrid.size()); - return dataGrid.get(dataRow).get(p.xIndex); - } - - private Number findBlerpHeight(Vert3D p) { - // Used by cylinder mode (and legacy paths). Works on activeHeightField if present. - if (activeHeightField != null) { - int w = activeHeightField.width(); - int h = activeHeightField.height(); - if (w <= 0 || h <= 0) return 0.0; - - int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; - if (x1Index >= w - 1) x1Index = w - 1; - int x2Index = p.xIndex >= w - 1 ? w - 1 : p.xIndex + 1; - - int y1Index = p.yIndex <= 0 ? 0 : p.yIndex - 1; - if (y1Index >= h - 1) y1Index = h - 1; - int y2Index = p.yIndex >= h - 1 ? h - 1 : p.yIndex + 1; - - float[] d = activeHeightField.data(); - double c11 = d[y1Index * w + x1Index] * yScale; - double c21 = d[y1Index * w + x2Index] * yScale; - double c12 = d[y2Index * w + x1Index] * yScale; - double c22 = d[y2Index * w + x2Index] * yScale; - return quickBlerp(c11, c21, c12, c22, p.getX(), p.getY()); - } - - if (dataGrid == null) return 0.0; - - int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; - if (x1Index >= dataGrid.get(0).size() - 1) x1Index = dataGrid.get(0).size() - 1; - int x2Index = p.xIndex >= dataGrid.get(0).size() - 1 ? dataGrid.get(0).size() - 1 : p.xIndex + 1; - int y1Index = p.yIndex <= 0 ? 0 : p.yIndex - 1; - if (y1Index >= dataGrid.size() - 1) y1Index = dataGrid.size() - 1; - int y2Index = p.yIndex >= dataGrid.size() - 1 ? dataGrid.size() - 1 : p.yIndex + 1; - double c11 = dataGrid.get(y1Index).get(x1Index) * yScale; - double c21 = dataGrid.get(y1Index).get(x2Index) * yScale; - double c12 = dataGrid.get(y2Index).get(x1Index) * yScale; - double c22 = dataGrid.get(y2Index).get(x2Index) * yScale; - return quickBlerp(c11, c21, c12, c22, p.getX(), p.getY()); - } - - private Number quickBlerp(double f1, double f2, double f3, double f4, double x, double y) { - double xratio = x - Math.floor(x); - double yratio = y - Math.floor(y); - double f12 = f1 + (f2 - f1) * xratio; - double f34 = f3 + (f4 - f3) * xratio; - return f12 + (f34 - f12) * yratio; - } - - Point3D vertP3D; - - private void loadSurf3D() { - LOG.info("Rendering Hypersurface Mesh..."); - generateRandos(xWidth, zWidth, yScale); - captureDataGridAsSource(); - surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, 1, 1, yScale, surfScale, vert3DLookup); - surfPlot.setRowOrientation(getSurfaceRowOrientation()); - surfPlot.setHeightOrientation(getSurfaceHeightOrientation()); - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); - surfPlot.setDrawMode(DrawMode.FILL); - sceneRoot.getChildren().add(surfPlot); - surfPlot.setCullFace(CullFace.BACK); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - - - // Initialize LOD manager (screen-space selection + throttle/debounce scheduling) - if (lodManager == null) { - lodManager = new LodManager(camera, subScene, surfPlot); - LodManager.LodConfig cfg = new LodManager.LodConfig(); - cfg.targetPixelsPerCell = 1.5; - cfg.lowThreshold = 1.0; - cfg.highThreshold = 2.0; - cfg.throttleMs = 75; - cfg.debounceMs = 75; - lodManager.setConfig(cfg); - lodManager.setOnLodSelected(idx -> { - if (!shouldUseTiledHeightFieldRenderer()) applyActiveLodIndex(idx); - }); - } - - if (tiledSurfaceRenderer == null) { - tiledSurfaceRenderer = new TiledSurfaceRenderer(camera, subScene); - tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); - tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); - tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); - tiledSurfaceRenderer.setVisible(false); - tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); - sceneRoot.getChildren().add(tiledSurfaceRenderer); - tiledSurfaceRenderer.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { - if (!hoverInteractionsEnabled) return; - Node picked = e.getPickResult().getIntersectedNode(); - javafx.geometry.Point3D pickedPoint = e.getPickResult().getIntersectedPoint(); - if (picked == null || pickedPoint == null) return; - javafx.geometry.Point3D scenePoint = picked.localToScene(pickedPoint); - javafx.geometry.Point3D surfacePoint = tiledSurfaceRenderer.sceneToLocal(scenePoint); - handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(surfacePoint)); - e.consume(); - }); - } - - // Build processed pyramid and apply initial LOD. The primitive source cache is - // created by rebuildProcessedGridAndRefresh() from the current source snapshot. - rebuildProcessedGridAndRefresh(); - - surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { - if (!hoverInteractionsEnabled) return; - javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); - if (p3D == null) return; - handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(p3D)); - e.consume(); - }); - - Glow glow = new Glow(0.8); - double poleHeight = 60; - double radius = 3; - glowLineBox = new Box(getWorldWidth(), poleHeight, radius); - glowLineBox.setMaterial(new PhongMaterial(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.2))); - glowLineBox.setDrawMode(DrawMode.FILL); - glowLineBox.setEffect(glow); - glowLineBox.setTranslateZ(-getWorldDepth() / 2.0); - eastPole = new Cylinder(radius * 2, poleHeight * 1.2); - westPole = new Cylinder(radius * 2, poleHeight * 1.2); - eastKnob = new Sphere(radius * 3); - westKnob = new Sphere(radius * 3); - PhongMaterial eastPoleMaterial = new PhongMaterial(Color.STEELBLUE); - PhongMaterial westPoleMaterial = new PhongMaterial(Color.STEELBLUE); - PhongMaterial knobMaterial = new PhongMaterial(Color.ALICEBLUE); - eastPole.setMaterial(eastPoleMaterial); - westPole.setMaterial(westPoleMaterial); - eastKnob.setMaterial(knobMaterial); - westKnob.setMaterial(knobMaterial); - eastPole.setTranslateX(getWorldWidth() / 2.0); - westPole.setTranslateX(-getWorldWidth() / 2.0); - eastKnob.setTranslateX(getWorldWidth() / 2.0); - westKnob.setTranslateX(-getWorldWidth() / 2.0); - eastKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); - westKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); - eastPole.translateZProperty().bind(glowLineBox.translateZProperty()); - westPole.translateZProperty().bind(glowLineBox.translateZProperty()); - eastKnob.translateZProperty().bind(glowLineBox.translateZProperty()); - westKnob.translateZProperty().bind(glowLineBox.translateZProperty()); - - eastLabel = new Label("Data Index"); - eastLabel.setTextFill(Color.ALICEBLUE); - eastLabel.setFont(new Font("calibri", 20)); - westLabel = new Label("Data Index"); - westLabel.setTextFill(Color.ALICEBLUE); - westLabel.setFont(new Font("calibri", 20)); - labelGroup.getChildren().addAll(eastLabel, westLabel); - shape3DToLabel.put(eastKnob, eastLabel); - shape3DToLabel.put(westKnob, westLabel); - - scene.addEventHandler(TimelineEvent.TIMELINE_SAMPLE_INDEX, e -> { - anchorIndex = (int) e.object; - if (anchorIndex < 0) anchorIndex = 0; - else if (anchorIndex > dataGrid.size()) anchorIndex = dataGrid.size(); - glowLineBox.setTranslateZ((anchorIndex * surfScale) - (getWorldDepth() / 2.0)); - setSpheroidAnchor(true, anchorIndex); - eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + getSourceWidth()); - westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); - updateLabels(); - updateCalloutHeadPoints(subScene); - }); - scene.addEventHandler(FeatureVectorEvent.SELECT_FEATURE_VECTOR, e -> { - if (null != anchorCallout) { - FeatureVector fv = (FeatureVector) e.object; - updateCalloutByFeatureVector(anchorCallout, fv); - } - }); - - extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); - wireEventHandlers(); - - pointLight.getScope().addAll(surfPlot, tiledSurfaceRenderer, graphLayer); - sceneRoot.getChildren().add(pointLight); - pointLight.translateXProperty().bind(camera.translateXProperty()); - pointLight.translateYProperty().bind(camera.translateYProperty()); - pointLight.translateZProperty().bind(camera.translateZProperty().add(500)); - ambientLight.getScope().addAll(surfPlot, tiledSurfaceRenderer, graphLayer); - sceneRoot.getChildren().add(ambientLight); - - updateLabels(); - subScene.sceneProperty().addListener(c -> { - Platform.runLater(() -> { - FeatureVector dummy = FeatureVector.EMPTY_FEATURE_VECTOR("", 3); - anchorCallout = createCallout(highlightedPoint, dummy, subScene); - anchorCallout.play().setOnFinished(fin -> anchorCallout.setVisible(false)); - }); - }); - } - - /** - * Fires HypersurfaceEvent GUI sync events for all core geometry controls - * (xWidth, zWidth, yScale, surfScale) to synchronize GUI controls with model state. - */ - public void syncGuiControls() { - // SET_*_GUI events update Spinner values synchronously. Those Spinner value - // listeners echo XWIDTH_CHANGED/ZWIDTH_CHANGED/etc. back through the Scene. - // Mark the entire model -> GUI synchronization window so those echoes do not - // trigger geometry rebuilds. - syncingGuiControls = true; - try { - fireOnRoot(HypersurfaceEvent.setXWidthGUI(xWidth)); - fireOnRoot(HypersurfaceEvent.setZWidthGUI(zWidth)); - fireOnRoot(HypersurfaceEvent.setYScaleGUI(yScale)); - fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); - } finally { - syncingGuiControls = false; - } - } - - /** - * Helper to fire on the JavaFX root, or self as fallback (copy this if not already present) - */ - private void fireOnRoot(Event evt) { - if (scene != null && scene.getRoot() != null) { - scene.getRoot().fireEvent(evt); - } else { - this.fireEvent(evt); - } - } - - /** - * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. - * Updates all rendering state and triggers updates as needed. - */ - private void wireEventHandlers() { - if (scene == null) return; - // Geometry / scale - scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { - if (syncingGuiControls) return; - this.xWidth = (int) e.object; - // When an LOD pyramid is active, its HeightField dimensions own the render - // mesh. Do not let GUI synchronization overwrite the constant-world transform. - if (surfPlot != null && activeHeightField == null) { - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - } - updateTheMesh(); - }); - - scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { - if (syncingGuiControls) return; - this.zWidth = (int) e.object; - if (surfPlot != null && activeHeightField == null) { - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - } - updateTheMesh(); - }); - - scene.addEventHandler(HypersurfaceEvent.Y_SCALE_CHANGED, e -> { - if (syncingGuiControls) return; - this.yScale = ((Double) e.object).floatValue(); - if (surfPlot != null) surfPlot.setFunctionScale(yScale); - updateTheMesh(); - }); - - scene.addEventHandler(HypersurfaceEvent.SURF_SCALE_CHANGED, e -> { - if (syncingGuiControls) return; - this.surfScale = ((Double) e.object).floatValue(); - refreshWorldExtentsAndLodMetadata(); - updateTheMesh(); - }); - - // Rendering modes - scene.addEventHandler(HypersurfaceEvent.SURFACE_RENDER_CHANGED, e -> { - this.surfaceRender = (boolean) e.object; - updateTheMesh(); - }); - scene.addEventHandler(HypersurfaceEvent.DRAW_MODE_CHANGED, e -> { - DrawMode drawMode = (DrawMode) e.object; - if (surfPlot != null) surfPlot.setDrawMode(drawMode); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setDrawMode(drawMode); - }); - scene.addEventHandler(HypersurfaceEvent.CULL_FACE_CHANGED, e -> { - CullFace cullFace = (CullFace) e.object; - if (surfPlot != null) surfPlot.setCullFace(cullFace); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setCullFace(cullFace); - }); - scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { - COLORATION previous = this.colorationMethod; - COLORATION next = (Hypersurface3DPane.COLORATION) e.object; - this.colorationMethod = next; - - // Shapley currently uses the legacy mesh metadata path. Crossing into or - // out of that mode therefore requires a geometry rebuild. Image <-> feature - // coloration only changes UV/material state on the direct mesh. - boolean shapleyPathChanged = (previous == COLORATION.COLOR_BY_SHAPLEY) - != (next == COLORATION.COLOR_BY_SHAPLEY); - if (shapleyPathChanged) { - if (next == COLORATION.COLOR_BY_SHAPLEY - && lodManager != null - && lodProcessedLevels != null - && !lodProcessedLevels.isEmpty()) { - activeLodIndex = -1; - activeHeightField = null; - lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, baseWorldDepth, -1); - lodManager.forceUpdate(); - } else if (shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - } - updateTheMesh(); - } else { - applyCurrentColoration(); - } - }); - - // Processing pipeline - scene.addEventHandler(HypersurfaceEvent.HEIGHT_MODE_CHANGED, e -> { - this.heightMode = (HeightMode) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.SMOOTHING_ENABLE_CHANGED, e -> { - this.smoothingEnabled = (boolean) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.SMOOTHING_METHOD_CHANGED, e -> { - this.smoothingMethod = (SurfaceUtils.Smoothing) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.SMOOTHING_RADIUS_CHANGED, e -> { - this.smoothingRadius = (int) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.GAUSSIAN_SIGMA_CHANGED, e -> { - this.gaussianSigma = (double) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.INTERP_MODE_CHANGED, e -> { - this.interpMode = (SurfaceUtils.Interpolation) e.object; - updateTheMesh(); - }); - scene.addEventHandler(HypersurfaceEvent.TONEMAP_ENABLE_CHANGED, e -> { - this.toneEnabled = (boolean) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.TONEMAP_OPERATOR_CHANGED, e -> { - this.toneOperator = (SurfaceUtils.ToneMap) e.object; - rebuildProcessedGridAndRefresh(); - }); - scene.addEventHandler(HypersurfaceEvent.TONEMAP_PARAM_CHANGED, e -> { - this.toneParam = (double) e.object; - rebuildProcessedGridAndRefresh(); - }); - - // Lighting - scene.addEventHandler(HypersurfaceEvent.AMBIENT_ENABLED_CHANGED, e -> { - // (Optional: enable/disable ambientLight as desired) - }); - scene.addEventHandler(HypersurfaceEvent.AMBIENT_COLOR_CHANGED, e -> { - if (ambientLight != null) ambientLight.setColor((Color) e.object); - }); - scene.addEventHandler(HypersurfaceEvent.POINT_ENABLED_CHANGED, e -> { - // (Optional: enable/disable pointLight as desired) - }); - scene.addEventHandler(HypersurfaceEvent.SPECULAR_COLOR_CHANGED, e -> { - Color color = (Color) e.object; - if (surfPlot != null && surfPlot.getMaterial() instanceof PhongMaterial mat) - mat.setSpecularColor(color); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setSpecularColor(color); - }); - - // UX toggles - scene.addEventHandler(HypersurfaceEvent.HOVER_ENABLE_CHANGED, e -> { - hoverInteractionsEnabled = (boolean) e.object; - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); - } - }); - scene.addEventHandler(HypersurfaceEvent.SURFACE_CHARTS_ENABLE_CHANGED, e -> surfaceChartsEnabled = (boolean) e.object); - scene.addEventHandler(HypersurfaceEvent.DATA_MARKERS_ENABLE_CHANGED, e -> extrasGroup.setVisible((boolean) e.object)); - scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> crosshairsEnabled = (boolean) e.object); - - // Commands/actions - scene.addEventHandler(HypersurfaceEvent.RESET_VIEW, e -> resetView(1000, false)); - scene.addEventHandler(HypersurfaceEvent.UPDATE_RENDER, e -> updateTheMesh()); - scene.addEventHandler(HypersurfaceEvent.CLEAR_DATA, e -> clearAll()); - scene.addEventHandler(HypersurfaceEvent.UNROLL_REQUESTED, e -> unrollHyperspace()); - scene.addEventHandler(HypersurfaceEvent.COMPUTE_VECTOR_DISTANCES, e -> computeVectorDistances()); - scene.addEventHandler(HypersurfaceEvent.COMPUTE_COLLECTION_DIFF, e -> computeSurfaceDifference((FeatureCollection) e.object)); - scene.addEventHandler(HypersurfaceEvent.COMPUTE_COSINE_DISTANCE, e -> computeCosineDistance((FeatureCollection) e.object)); - // Hover a node: emit its similarity row (from graph) and optionally highlight a surface row - scene.addEventHandler(GraphEvent.GRAPH_NODE_HOVER, e -> { - if (!(e.object instanceof GraphNode gNode)) return; - - // Build a row vector from the sparse graph - Double[] row = buildSimilarityRowFromGraph(gNode); - - // Publish for analysis panels/plots (reuses your existing pattern) - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Similarity Row (hover): " + gNode, - row - )); - - // Optional: try to relate back to hypersurface row - highlightSurfaceRowIfPossible(gNode); - }); - - // Click a node: same as hover but labeled and could be made "sticky" - scene.addEventHandler(GraphEvent.GRAPH_NODE_CLICK, e -> { - if (!(e.object instanceof GraphNode gNode)) return; - Double[] row = buildSimilarityRowFromGraph(gNode); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Similarity Row (click): " + gNode, - row - )); - highlightSurfaceRowIfPossible(gNode); - }); - - // Hover an edge: emit its endpoints + weight - scene.addEventHandler(GraphEvent.GRAPH_EDGE_HOVER, e -> { - if (!(e.object instanceof GraphEdge ge)) return; - - Optional a = currentGraph != null ? currentGraph.findNodeById(ge.getStartID()) : Optional.empty(); - Optional b = currentGraph != null ? currentGraph.findNodeById(ge.getEndID()) : Optional.empty(); - double w = getEdgeWeightSafe(ge); - - scene.getRoot().fireEvent(new CommandTerminalEvent( - "Edge hover: " + a.map(Object::toString).orElse("?") + " → " + - b.map(Object::toString).orElse("?") + " | weight = " + w, - new Font("Consolas", 16), Color.ALICEBLUE - )); - }); - - // Click an edge: emit a tiny 2-entry vector [w] or a pairwise slice if you prefer - scene.addEventHandler(GraphEvent.GRAPH_EDGE_CLICK, e -> { - if (!(e.object instanceof GraphEdge ge)) return; - double w = getEdgeWeightSafe(ge); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Edge Weight (click): " + ge.getStartID() + " → " + ge.getEndID(), - new Double[]{w} - )); - }); - scene.addEventHandler(GraphEvent.GRAPH_VISIBILITY_CHANGED, e -> { - if (!(e.object instanceof Boolean b)) return; - graphVisible = b; - graphLayer.setVisible(graphVisible); - }); - } - - public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { - callout.setMainTitleText(featureVector.getLabel()); - callout.mainTitleTextNode.setText(callout.getMainTitleText()); - VBox vbox = (VBox) callout.mainTitleNode; - TitledPane tp0 = (TitledPane) vbox.getChildren().get(0); - ImageView iv = loadImageView(featureVector, featureVector.isBBoxValid()); - Image image = iv.getImage(); - ((ImageView) tp0.getContent()).setImage(image); - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : featureVector.getMetaData().entrySet()) - sb.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); - TitledPane tp1 = (TitledPane) vbox.getChildren().get(1); - ((Text) tp1.getContent()).setText(sb.toString()); - } - - private void addDebugPoint(Point3D point3D) { - Sphere sphere = new Sphere(1); - PhongMaterial mat = new PhongMaterial(Color.ALICEBLUE); - sphere.setMaterial(mat); - sphere.setTranslateX(point3D.x); - sphere.setTranslateY(point3D.y); - sphere.setTranslateZ(point3D.z); - extrasGroup.getChildren().add(sphere); - Label newLabel = new Label(point3D.toString()); - labelGroup.getChildren().addAll(newLabel); - newLabel.setTextFill(Color.SKYBLUE); - newLabel.setFont(new Font("calibri", 8)); - shape3DToLabel.put(sphere, newLabel); - } - - public void clearAll() { - xFactorIndex = 0; - yFactorIndex = 1; - zFactorIndex = 2; - Platform.runLater(() -> scene.getRoot().fireEvent( - new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, - new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - notifyIndexChange(); - ellipsoidGroup.getChildren().clear(); - shape3DToLabel.clear(); - shape3DToLabel.put(xSphere, xLabel); - shape3DToLabel.put(ySphere, yLabel); - shape3DToLabel.put(zSphere, zLabel); - shape3DToLabel.put(highlightedPoint, hoverText); - dataGrid.clear(); - featureVectors.clear(); - originalGrid.clear(); - resetLodDataState(); - } - - public void showAll() { - updateView(true); - } - - public void hideFA3D() { - Timeline timeline = new Timeline( - new KeyFrame(Duration.seconds(0.1), new KeyValue(opacityProperty(), 1.0)), - new KeyFrame(Duration.seconds(0.2), e -> outtro(1000)), - new KeyFrame(Duration.seconds(2.0), new KeyValue(opacityProperty(), 0.0)), - new KeyFrame(Duration.seconds(2.0), e -> setVisible(false)), - new KeyFrame(Duration.seconds(2.1), e -> setOpacity(1.0)) - ); - timeline.setOnFinished(e -> setVisible(false)); - timeline.playFromStart(); - } - - public void showFA3D() { - Timeline timeline = new Timeline( - new KeyFrame(Duration.seconds(0.1), e -> camera.setTranslateZ(DEFAULT_INTRO_DISTANCE)), - new KeyFrame(Duration.seconds(0.1), new KeyValue(opacityProperty(), 0.0)), - new KeyFrame(Duration.seconds(0.3), e -> setVisible(true)), - new KeyFrame(Duration.seconds(0.3), new KeyValue(opacityProperty(), 1.0)), - new KeyFrame(Duration.seconds(0.6), e -> intro(1000)) - ); - timeline.playFromStart(); - } - - @Override - public void setFeatureCollection(FeatureCollection fc) { - featureVectors = fc.getFeatures(); - } - - public void findClusters(ManifoldEvent.ProjectionConfig pc) { - if (pc.dataSource != ManifoldEvent.ProjectionConfig.DATA_SOURCE.HYPERSURFACE) return; - double[][] observations = FeatureCollection.toData(featureVectors); - double projectionScalar = 1000.0; - switch (pc.clusterMethod) { - case DBSCAN -> { - DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - case HDDBSCAN -> { - HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - case KMEANS -> { - KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - case KMEDIODS -> { - KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - case EX_MAX -> { - ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - case AFFINITY -> { - AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); - if (!t.isCancelledByUser()) { - Thread th = new Thread(t); - th.setDaemon(true); - th.start(); - } - } - } - } - - @Override - public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection) { - SemanticReconstruction reconstruction = semanticMapCollection.getReconstruction(); - SemanticReconstructionMap rMap = reconstruction.getData_vars().getNeural_timeseries(); - List> neuralData = rMap.getData(); - LOG.info("Neural Data dimensions: {} entries at {} frame width.", neuralData.size(), neuralData.get(0).size()); - long startTime = System.nanoTime(); - dataGrid.clear(); - List justTheMags; - for (List phaseMagPairs : neuralData) { - justTheMags = new ArrayList<>(neuralData.get(0).size() / 2); - for (int i = 0; i < phaseMagPairs.size(); i += 2) justTheMags.add(phaseMagPairs.get(i) * yScale); - dataGrid.add(justTheMags); - } - LOG.info("Mapped Neural Magnitudes to Hypersurface: {}", Utils.totalTimeString(startTime)); - zWidth = neuralData.size(); - xWidth = neuralData.get(0).size() / 2; - syncGuiControls(); - captureDataGridAsSource(); - rebuildProcessedGridAndRefresh(); // NEW - - xSphere.setTranslateX(getWorldWidth() / 2.0); - zSphere.setTranslateZ(getWorldDepth() / 2.0); - - double poleHeight = surfPlot.getMaxAbsY() * 2; - glowLineBox.setWidth(getWorldWidth()); - glowLineBox.setHeight(poleHeight); - eastPole.setHeight(poleHeight * 1.2); - westPole.setHeight(poleHeight * 1.2); - eastPole.setTranslateX(-getWorldWidth() / 2.0); - westPole.setTranslateX(getWorldWidth() / 2.0); - eastKnob.setTranslateX(getWorldWidth() / 2.0); - westKnob.setTranslateX(-getWorldWidth() / 2.0); - eastKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); - westKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); - eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + getSourceWidth()); - westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); - updateLabels(); - } - - @Override - public void addSemanticMap(SemanticMap semanticMap) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public SemanticMap getSemanticMap(long id) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void locateSemanticMap(SemanticMap semanticMap) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void clearSemanticMaps() { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void addFeatureCollection(FeatureCollection featureCollection, boolean clearQueue) { - if (null == dataGrid) dataGrid = new ArrayList<>(featureCollection.getFeatures().size()); - else dataGrid.clear(); - List xList; - for (FeatureVector fv : featureCollection.getFeatures()) { - xList = new ArrayList<>(fv.getData().size()); - xList.addAll(fv.getData()); - dataGrid.add(xList); - } - zWidth = dataGrid.size(); - xWidth = dataGrid.get(0).size(); - syncGuiControls(); - captureDataGridAsSource(); - rebuildProcessedGridAndRefresh(); - getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); - featureVectors = featureCollection.getFeatures(); - } - - @Override - public void addFeatureVector(FeatureVector featureVector) { - featureVectors.add(featureVector); - dataGrid.add(featureVector.getData()); - captureDataGridAsSource(); - rebuildProcessedGridAndRefresh(); - } - - @Override - public void locateFeatureVector(FeatureVector featureVector) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void clearFeatureVectors() { - featureVectors.clear(); - dataGrid.clear(); - originalGrid.clear(); - resetLodDataState(); - } - - @Override - public List getAllFeatureVectors() { - if (null == featureVectors) return Collections.EMPTY_LIST; - return featureVectors; - } - - @Override - public void setColorByID(String iGotID, Color color) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void setColorByIndex(int i, Color color) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void setVisibleByIndex(int i, boolean b) { - throw new UnsupportedOperationException("Not supported yet."); - } - - @Override - public void refresh() { - refresh(true); - } - - @Override - public void refresh(boolean forceNodeUpdate) { - updateTheMesh(); - } - - @Override - public void setDimensionLabels(List labelStrings) { - featureLabels = labelStrings; - } - - @Override - public void setSpheroidAnchor(boolean animate, int index) { - double z = index * surfScale; - } - - /** - * Builds the raw image L0 directly from PixelReader without first allocating a - * full-resolution float raster. For sources above maxRender this reduces both - * persistent heap usage and source-pixel reads substantially. - */ - private static HeightField buildImageRawL0(PixelReader pixelReader, - int startX, int startY, - int sourceWidth, int sourceHeight, - int maxRender) { - int[] dims = SurfaceUtils.computeL0Dimensions(sourceWidth, sourceHeight, maxRender); - int targetWidth = dims[0]; - int targetHeight = dims[1]; - float[] out = new float[Math.multiplyExact(targetWidth, targetHeight)]; - - if (targetWidth == sourceWidth && targetHeight == sourceHeight) { - int index = 0; - for (int y = 0; y < sourceHeight; y++) { - int sourceY = startY + y; - for (int x = 0; x < sourceWidth; x++) { - out[index++] = grayscale(pixelReader.getArgb(startX + x, sourceY)); - } - } - return new HeightField(targetWidth, targetHeight, out); - } - - double xRatio = sourceWidth / (double) targetWidth; - double yRatio = sourceHeight / (double) targetHeight; - for (int y = 0; y < targetHeight; y++) { - double gy = (y + 0.5) * yRatio - 0.5; - gy = Math.max(0.0, Math.min(sourceHeight - 1.0, gy)); - int y0 = (int) Math.floor(gy); - int y1 = Math.min(sourceHeight - 1, y0 + 1); - double ty = y0 == y1 ? 0.0 : gy - y0; - - for (int x = 0; x < targetWidth; x++) { - double gx = (x + 0.5) * xRatio - 0.5; - gx = Math.max(0.0, Math.min(sourceWidth - 1.0, gx)); - int x0 = (int) Math.floor(gx); - int x1 = Math.min(sourceWidth - 1, x0 + 1); - double tx = x0 == x1 ? 0.0 : gx - x0; - - double c00 = grayscale(pixelReader.getArgb(startX + x0, startY + y0)); - double c10 = grayscale(pixelReader.getArgb(startX + x1, startY + y0)); - double c01 = grayscale(pixelReader.getArgb(startX + x0, startY + y1)); - double c11 = grayscale(pixelReader.getArgb(startX + x1, startY + y1)); - double top = c00 + (c10 - c00) * tx; - double bottom = c01 + (c11 - c01) * tx; - out[y * targetWidth + x] = (float) (top + (bottom - top) * ty); - } - } - return new HeightField(targetWidth, targetHeight, out); - } - - private static float grayscale(int argb) { - int red = (argb >> 16) & 0xFF; - int green = (argb >> 8) & 0xFF; - int blue = argb & 0xFF; - return (float) (((red + green + blue) / 3.0) / 255.0); - } - - private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { - final int imageWidth = (int) image.getWidth(); - final int imageHeight = (int) image.getHeight(); - final int startX = Math.max(0, Math.min(x1, imageWidth)); - final int startY = Math.max(0, Math.min(y1, imageHeight)); - final int endX = Math.max(startX, Math.min(x2, imageWidth)); - final int endY = Math.max(startY, Math.min(y2, imageHeight)); - final int sourceWidth = endX - startX; - final int sourceHeight = endY - startY; - - if (sourceWidth <= 0 || sourceHeight <= 0) { - LOG.warn("Ignoring empty image tessellation region: ({}, {}) to ({}, {}) for {}x{} image", - x1, y1, x2, y2, imageWidth, imageHeight); - return; - } - - long startTime = System.nanoTime(); - System.out.println("Mapping Image Raster directly to capped primitive L0: " - + sourceWidth + "x" + sourceHeight); - - PixelReader pixelReader = image.getPixelReader(); - if (pixelReader == null) { - LOG.warn("Unable to tessellate image because PixelReader is unavailable."); - return; - } - - // Validate the incoming image first, then release every large render-side object - // derived from the previous image before allocating the new full-resolution - // primitive height array. This minimizes the transient heap peak when replacing - // 4K/8K sources. The method parameter retains the new Image while lastImage and - // the tiled renderer are allowed to release the old one. - releaseImageBackedStateForReplacement(); - lastImage = image; - - // Retain source-region metadata, but do not allocate a full-resolution - // HeightField for large images. Build the capped raw L0 directly from PixelReader. - imageSourceStartX = startX; - imageSourceStartY = startY; - imageSourceWidth = sourceWidth; - imageSourceHeight = sourceHeight; - processingSourceHeightField = buildImageRawL0(pixelReader, - startX, startY, sourceWidth, sourceHeight, maxRenderResolution); - - // Image-backed hypersurfaces use the capped primitive L0 as the authoritative - // processing source. Do not manufacture one FeatureVector or boxed Double per pixel. - featureVectors.clear(); - if (dataGrid == null) dataGrid = new ArrayList<>(); - else dataGrid.clear(); - if (originalGrid == null) originalGrid = new ArrayList<>(); - else originalGrid.clear(); - imageBackedSurface = true; - - // A new primitive source invalidates only state derived from the previous source. - lodProcessedLevels = List.of(); - activeLodIndex = -1; - activeHeightField = null; - baseWorldWidth = Double.NaN; - baseWorldDepth = Double.NaN; - currentLodSurfScaleX = Double.NaN; - currentLodSurfScaleZ = Double.NaN; - setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_FAR); - - Utils.printTotalTime(startTime); - System.out.println("Injecting primitive image HeightField into Hypersurface..."); - startTime = System.nanoTime(); - - xWidth = sourceWidth; - zWidth = sourceHeight; - syncGuiControls(); - rebuildProcessedGridAndRefresh(); - xSphere.setTranslateX(getWorldWidth() / 2.0); - zSphere.setTranslateZ(getWorldDepth() / 2.0); - Utils.printTotalTime(startTime); - } - - @Override - public void addShapleyCollection(ShapleyCollection shapleyCollection) { - shapleyVectors.clear(); - lastImageSource = shapleyCollection.getSourceInput(); - shapleyVectors.addAll(shapleyCollection.getValues()); - try { - WritableImage wi = ResourceUtils.loadImageFile(imageryBasePath + shapleyCollection.getSourceInput()); - if (null != wi) { - int x2 = (int) wi.getWidth(); - int y2 = (int) wi.getHeight(); - tessellateImage(wi, 0, 0, x2, y2); - lastImage = wi; - LOG.info("injecting Shapley function values into Vertices... "); - long startTime = System.nanoTime(); - surfPlot.functionValues.clear(); - for (int i = 0; i < shapleyVectors.size(); i++) { - surfPlot.functionValues.add(shapleyVectors.get(i).getData().get(0) * yScale); - } - Utils.printTotalTime(startTime); - if (colorationMethod == COLORATION.COLOR_BY_SHAPLEY) { - // Rebuild through the legacy metadata path so p.f remains available. - updateTheMesh(); - } else { - applyCurrentColoration(); - } - } - } catch (IOException ex) { - LOG.error(null, ex); - } - } - - @Override - public void addShapleyVector(ShapleyVector shapleyVector) { - shapleyVectors.add(shapleyVector); - } - - @Override - public void clearShapleyVectors() { - shapleyVectors.clear(); - } - - // ================= helpers for processing pipeline ================= - private static List> deepCopyGrid(List> src) { - List> out = new ArrayList<>(src.size()); - for (List row : src) out.add(new ArrayList<>(row)); - return out; - } - - /** - * Releases render-side state owned by the previous image before a replacement - * image allocates its capped primitive L0 HeightField. Keeping this separate - * from resetLodDataState() preserves the incoming source type/orientation workflow - * while aggressively dropping references to large cached TriangleMeshes, LOD - * HeightFields, and the previous JavaFX Image. - */ - private void releaseImageBackedStateForReplacement() { - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.clearSurface(); - tiledSurfaceRenderer.setVisible(false); - } - if (surfPlot != null) { - surfPlot.setVisible(false); - } - if (paintMeshView != null) { - paintMeshView.setVisible(false); - } - - processingSourceHeightField = null; - imageSourceStartX = 0; - imageSourceStartY = 0; - imageSourceWidth = 0; - imageSourceHeight = 0; - lodProcessedLevels = List.of(); - activeLodIndex = -1; - activeHeightField = null; - baseWorldWidth = Double.NaN; - baseWorldDepth = Double.NaN; - currentLodSurfScaleX = Double.NaN; - currentLodSurfScaleZ = Double.NaN; - - // Drop the pane's reference to the previous image. The incoming Image remains - // strongly referenced by tessellateImage() and is assigned after this release. - lastImage = null; - } - - /** - * Snapshot the current boxed working grid as the authoritative source for the - * render pipeline. Any primitive source cache and render pyramid derived from the - * previous source become invalid immediately. - */ - private void captureDataGridAsSource() { - // Boxed grids represent data/FeatureCollection-backed surfaces, whose historical - // world-size behavior remains sourceDimension * surfScale. - boolean wasImageBacked = imageBackedSurface; - imageBackedSurface = false; - if (wasImageBacked) { - setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); - } - if (dataGrid == null || dataGrid.isEmpty()) { - originalGrid = new ArrayList<>(); - resetLodDataState(); - return; - } - originalGrid = deepCopyGrid(dataGrid); - processingSourceHeightField = null; - imageSourceStartX = 0; - imageSourceStartY = 0; - imageSourceWidth = 0; - imageSourceHeight = 0; - } - - private void resetLodDataState() { - imageBackedSurface = false; - setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); - processingSourceHeightField = null; - imageSourceStartX = 0; - imageSourceStartY = 0; - imageSourceWidth = 0; - imageSourceHeight = 0; - lodProcessedLevels = List.of(); - activeLodIndex = -1; - activeHeightField = null; - baseWorldWidth = Double.NaN; - baseWorldDepth = Double.NaN; - currentLodSurfScaleX = Double.NaN; - currentLodSurfScaleZ = Double.NaN; - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.clearSurface(); - tiledSurfaceRenderer.setVisible(false); - } - } - - private int getSourceWidth() { - if (imageBackedSurface && imageSourceWidth > 0) return imageSourceWidth; - if (processingSourceHeightField != null) return processingSourceHeightField.width(); - if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.get(0).size(); - if (dataGrid != null && !dataGrid.isEmpty()) return dataGrid.get(0).size(); - return Math.max(1, xWidth); - } - - private int getSourceHeight() { - if (imageBackedSurface && imageSourceHeight > 0) return imageSourceHeight; - if (processingSourceHeightField != null) return processingSourceHeightField.height(); - if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.size(); - if (dataGrid != null && !dataGrid.isEmpty()) return dataGrid.size(); - return Math.max(1, zWidth); - } - - private int getRenderWidth() { - return activeHeightField != null ? activeHeightField.width() : Math.max(1, xWidth); - } - - private int getRenderHeight() { - return activeHeightField != null ? activeHeightField.height() : Math.max(1, zWidth); - } - - private double getRenderScaleX() { - return Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; - } - - private double getRenderScaleZ() { - return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; - } - - private double getWorldWidth() { - if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; - int sourceWidth = getSourceWidth(); - int sourceHeight = getSourceHeight(); - return calculateWorldWidth(sourceWidth, sourceHeight); - } - - private double getWorldDepth() { - if (Double.isFinite(baseWorldDepth)) return baseWorldDepth; - int sourceWidth = getSourceWidth(); - int sourceHeight = getSourceHeight(); - return calculateWorldDepth(sourceWidth, sourceHeight); - } - - private double imageWorldLongAxisExtent() { - return nominalImageWorldExtent * (surfScale / imageWorldExtentReferenceScale); - } - - private double calculateWorldWidth(int sourceWidth, int sourceHeight) { - if (!imageBackedSurface) return sourceWidth * surfScale; - if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; - double longAxis = imageWorldLongAxisExtent(); - if (sourceWidth >= sourceHeight) return longAxis; - return longAxis * sourceWidth / (double) sourceHeight; - } - - private double calculateWorldDepth(int sourceWidth, int sourceHeight) { - if (!imageBackedSurface) return sourceHeight * surfScale; - if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; - double longAxis = imageWorldLongAxisExtent(); - if (sourceHeight >= sourceWidth) return longAxis; - return longAxis * sourceHeight / (double) sourceWidth; - } - - private void recomputeBaseWorldExtents() { - int sourceWidth = getSourceWidth(); - int sourceHeight = getSourceHeight(); - baseWorldWidth = calculateWorldWidth(sourceWidth, sourceHeight); - baseWorldDepth = calculateWorldDepth(sourceWidth, sourceHeight); - } - - private static int mapRenderIndexToSource(int renderIndex, int renderSize, int sourceSize) { - if (sourceSize <= 1 || renderSize <= 1) return 0; - double sourceCoordinate = ((renderIndex + 0.5) * sourceSize / (double) renderSize) - 0.5; - return Math.max(0, Math.min((int) Math.round(sourceCoordinate), sourceSize - 1)); - } - - private double getActiveRenderValue(int row, int column) { - if (activeHeightField == null) return 0.0; - int r = orientRowIndex(row, activeHeightField.height()); - int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); - return activeHeightField.get(c, r); - } - - private List getActiveRenderRow(int row) { - if (activeHeightField == null) return List.of(); - int r = orientRowIndex(row, activeHeightField.height()); - int w = activeHeightField.width(); - float[] values = activeHeightField.data(); - List out = new ArrayList<>(w); - int offset = r * w; - for (int x = 0; x < w; x++) out.add((double) values[offset + x]); - return out; - } - - private Double[] getActiveRenderColumn(int column) { - if (activeHeightField == null) return new Double[0]; - int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); - int w = activeHeightField.width(); - int h = activeHeightField.height(); - float[] values = activeHeightField.data(); - Double[] out = new Double[h]; - for (int visualRow = 0; visualRow < h; visualRow++) { - int sourceRow = orientRowIndex(visualRow, h); - out[visualRow] = (double) values[sourceRow * w + c]; - } - return out; - } - - /** Recompute world extents after surfScale changes without rebuilding source data. */ - private void refreshWorldExtentsAndLodMetadata() { - recomputeBaseWorldExtents(); - - if (activeHeightField != null) { - currentLodSurfScaleX = baseWorldWidth / activeHeightField.width(); - currentLodSurfScaleZ = baseWorldDepth / activeHeightField.height(); - } - if (surfPlot != null) { - surfPlot.setTranslateX(-baseWorldWidth / 2.0); - surfPlot.setTranslateZ(-baseWorldDepth / 2.0); - } - if (shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - tiledSurfaceRenderer.forceUpdate(); - } else if (lodManager != null && lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { - lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, - baseWorldDepth, - activeLodIndex - ); - lodManager.forceUpdate(); - } - } - - /** - * Returns the long-axis world extent used for image-backed surfaces at the default - * surface scale. Image aspect ratio determines the other axis. - */ - public double getNominalImageWorldExtent() { - return nominalImageWorldExtent; - } - - /** - * Sets the long-axis world extent used for image-backed surfaces at the default - * surface scale. This changes physical display size, not source or LOD resolution. - */ - public void setNominalImageWorldExtent(double nominalImageWorldExtent) { - if (!(nominalImageWorldExtent > 0.0) || !Double.isFinite(nominalImageWorldExtent)) { - throw new IllegalArgumentException("nominalImageWorldExtent must be finite and > 0"); - } - if (Double.compare(this.nominalImageWorldExtent, nominalImageWorldExtent) == 0) return; - this.nominalImageWorldExtent = nominalImageWorldExtent; - if (imageBackedSurface && processingSourceHeightField != null) { - refreshWorldExtentsAndLodMetadata(); - updateTheMesh(); - } - } - - public SurfaceRowOrientation getSurfaceRowOrientation() { - return surfaceRowOrientation.get(); - } - - public void setSurfaceRowOrientation(SurfaceRowOrientation orientation) { - surfaceRowOrientation.set(orientation != null - ? orientation - : SurfaceRowOrientation.FIRST_ROW_NEAR); - } - - public ObjectProperty surfaceRowOrientationProperty() { - return surfaceRowOrientation; - } - - private void setSourceDefaultRowOrientation(SurfaceRowOrientation orientation) { - suppressRowOrientationRefresh = true; - try { - setSurfaceRowOrientation(orientation); - } finally { - suppressRowOrientationRefresh = false; - } - } - - private void applySurfaceRowOrientationToRenderers() { - SurfaceRowOrientation orientation = getSurfaceRowOrientation(); - if (surfPlot != null) surfPlot.setRowOrientation(orientation); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setRowOrientation(orientation); - } - - public SurfaceHeightOrientation getSurfaceHeightOrientation() { - return surfaceHeightOrientation.get(); - } - - public void setSurfaceHeightOrientation(SurfaceHeightOrientation orientation) { - surfaceHeightOrientation.set(orientation != null - ? orientation - : SurfaceHeightOrientation.HIGH_VALUES_UP); - } - - public ObjectProperty surfaceHeightOrientationProperty() { - return surfaceHeightOrientation; - } - - private void applySurfaceHeightOrientationToRenderers() { - SurfaceHeightOrientation orientation = getSurfaceHeightOrientation(); - if (surfPlot != null) surfPlot.setHeightOrientation(orientation); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setHeightOrientation(orientation); - } - - private int orientRowIndex(int logicalRow, int rowCount) { - if (rowCount <= 0) return 0; - int clamped = Math.max(0, Math.min(logicalRow, rowCount - 1)); - return getSurfaceRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR - ? rowCount - 1 - clamped - : clamped; - } - - private double orientRowCoordinate(double logicalRow, int rowCount) { - if (rowCount <= 1) return 0.0; - double clamped = Math.max(0.0, Math.min(logicalRow, rowCount - 1.0)); - return getSurfaceRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR - ? (rowCount - 1.0) - clamped - : clamped; - } - - public boolean isTiledHeightFieldRenderingEnabled() { - return tiledHeightFieldRenderingEnabled; - } - - public void setTiledHeightFieldRenderingEnabled(boolean enabled) { - if (this.tiledHeightFieldRenderingEnabled == enabled) return; - this.tiledHeightFieldRenderingEnabled = enabled; - if (lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { - if (enabled && shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - } else if (lodManager != null) { - activeLodIndex = -1; - activeHeightField = null; - lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, baseWorldDepth, -1); - lodManager.forceUpdate(); - } - updateTheMesh(); - } - } - - public int getTileCellsL0() { - return tileCellsL0; - } - - - public TiledLodManager.Config getTiledLodConfigCopy() { - return tiledSurfaceRenderer != null - ? tiledSurfaceRenderer.getLodConfigCopy() - : new TiledLodManager.Config(); - } - - public void setTiledLodConfig(TiledLodManager.Config config) { - if (tiledSurfaceRenderer == null || config == null) return; - tiledSurfaceRenderer.setLodConfig(config); - } - - public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { - return tiledSurfaceRenderer != null - ? tiledSurfaceRenderer.getLodStatistics() - : new TiledSurfaceRenderer.LodStatistics( - 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0, 0); - } - - public void setTileCellsL0(int tileCellsL0) { - if (tileCellsL0 < 16) { - throw new IllegalArgumentException("tileCellsL0 must be >= 16"); - } - if (this.tileCellsL0 == tileCellsL0) return; - this.tileCellsL0 = tileCellsL0; - if (shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - updateTheMesh(); - } - } - - public int getMaxRenderResolution() { - return maxRenderResolution; - } - - public void setMaxRenderResolution(int maxRenderResolution) { - if (maxRenderResolution <= 0) { - throw new IllegalArgumentException("maxRenderResolution must be > 0"); - } - if (maxRenderResolution < minRenderResolution) { - throw new IllegalArgumentException("maxRenderResolution must be >= minRenderResolution"); - } - if (this.maxRenderResolution == maxRenderResolution) return; - this.maxRenderResolution = maxRenderResolution; - if (imageBackedSurface && lastImage != null && imageSourceWidth > 0 && imageSourceHeight > 0) { - PixelReader reader = lastImage.getPixelReader(); - if (reader != null) { - processingSourceHeightField = buildImageRawL0(reader, - imageSourceStartX, imageSourceStartY, - imageSourceWidth, imageSourceHeight, maxRenderResolution); - } - } - rebuildProcessedGridAndRefresh(); - } - - public int getMinRenderResolution() { - return minRenderResolution; - } - - public void setMinRenderResolution(int minRenderResolution) { - if (minRenderResolution <= 0) { - throw new IllegalArgumentException("minRenderResolution must be > 0"); - } - if (minRenderResolution > maxRenderResolution) { - throw new IllegalArgumentException("minRenderResolution must be <= maxRenderResolution"); - } - if (this.minRenderResolution == minRenderResolution) return; - this.minRenderResolution = minRenderResolution; - rebuildProcessedGridAndRefresh(); - } - - private boolean shouldUseTiledHeightFieldRenderer() { - return tiledHeightFieldRenderingEnabled - && imageBackedSurface - && tiledSurfaceRenderer != null - && lodProcessedLevels != null - && !lodProcessedLevels.isEmpty() - && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; - } - - public boolean isTiledHeightFieldRendererActive() { - return shouldUseTiledHeightFieldRenderer() - && surfaceRender - && tiledSurfaceRenderer.isVisible(); - } - - private void requestLodUpdate(LodManager.UpdateReason reason) { - if (shouldUseTiledHeightFieldRenderer()) { - tiledSurfaceRenderer.requestUpdate(reason); - } else if (lodManager != null) { - lodManager.requestUpdate(reason); - } - } - - private void forceLodUpdate() { - if (shouldUseTiledHeightFieldRenderer()) { - tiledSurfaceRenderer.forceUpdate(); - } else if (lodManager != null) { - lodManager.forceUpdate(); - } - } - - private void configureTiledAppearance() { - if (tiledSurfaceRenderer == null) return; - if (surfPlot != null) { - tiledSurfaceRenderer.setDrawMode(surfPlot.getDrawMode()); - tiledSurfaceRenderer.setCullFace(surfPlot.getCullFace()); - if (surfPlot.getMaterial() instanceof PhongMaterial material) { - tiledSurfaceRenderer.setSpecularColor(material.getSpecularColor()); - } - } - tiledSurfaceRenderer.setYScale(yScale); - tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); - tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); - - if (colorationMethod == COLORATION.COLOR_BY_IMAGE && lastImage != null) { - tiledSurfaceRenderer.setColorByImage(lastImage); - } else if (colorationMethod == COLORATION.COLOR_BY_FEATURE && !lodProcessedLevels.isEmpty()) { - float[] minMax = lodProcessedLevels.get(0).minMax(); - double minColorValue = minMax[0] * yScale; - double maxColorValue = minMax[1] * yScale; - if (!(maxColorValue > minColorValue)) { - double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); - minColorValue -= pad; - maxColorValue += pad; - } - tiledSurfaceRenderer.setColorByHeight( - TOTAL_COLORS, minColorValue, maxColorValue); - } - } - - private void configureTiledPyramid() { - if (!shouldUseTiledHeightFieldRenderer()) return; - activeLodIndex = 0; - activeHeightField = lodProcessedLevels.get(0); - xWidth = activeHeightField.width(); - zWidth = activeHeightField.height(); - currentLodSurfScaleX = baseWorldWidth / xWidth; - currentLodSurfScaleZ = baseWorldDepth / zWidth; - - tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); - tiledSurfaceRenderer.setPyramid( - lodProcessedLevels, baseWorldWidth, baseWorldDepth, yScale); - configureTiledAppearance(); - tiledSurfaceRenderer.forceUpdate(); - syncGuiControls(); - } - - private void rebuildProcessedGridAndRefresh() { - // Process raw L0 once, then derive all lower LODs from that processed L0. - // Image-backed surfaces already retain a capped raw L0; boxed-grid-backed - // surfaces retain their source resolution and are capped here. - // - // Boxed-grid-backed surfaces continue to populate - // originalGrid and are converted lazily here for compatibility. - if (processingSourceHeightField == null) { - if (originalGrid == null || originalGrid.isEmpty()) return; - processingSourceHeightField = SurfaceUtils.toHeightField(originalGrid); - } - - HeightField l0Raw = imageBackedSurface - ? processingSourceHeightField - : SurfaceUtils.buildL0Raw(processingSourceHeightField, maxRenderResolution); - - SurfaceUtils.Smoothing sm = smoothingEnabled - ? smoothingMethod - : SurfaceUtils.Smoothing.NONE; - SurfaceUtils.ToneMap tm = toneEnabled - ? toneOperator - : SurfaceUtils.ToneMap.NONE; - - HeightField l0Processed = SurfaceUtils.processL0( - l0Raw, - sm, - smoothingRadius, - 1, - gaussianSigma, - tm, - toneParam - ); - - lodProcessedLevels = imageBackedSurface - ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) - : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); - - if (imageBackedSurface) { - StringBuilder lodSummary = new StringBuilder("Hypersurface tiled LOD pyramid: "); - for (int i = 0; i < lodProcessedLevels.size(); i++) { - if (i > 0) lodSummary.append(", "); - HeightField level = lodProcessedLevels.get(i); - lodSummary.append('L').append(i).append('=') - .append(level.width()).append('x').append(level.height()); - } - if (tiledSurfaceRenderer == null || tiledSurfaceRenderer.isVerboseDiagnosticsEnabled()) { - System.out.println(lodSummary); - } - } - - // Rendering resolution and physical world size are intentionally independent. - // Image-backed sources use a nominal long-axis world extent with aspect ratio - // preserved; data-grid-backed sources retain the historical dimension*scale rule. - recomputeBaseWorldExtents(); - - // A new pyramid invalidates the old active HeightField even when the numerical - // LOD index happens to be identical. - activeLodIndex = -1; - activeHeightField = null; - currentLodSurfScaleX = Double.NaN; - currentLodSurfScaleZ = Double.NaN; - - if (shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - updateTheMesh(); - } else if (lodManager != null) { - lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, - baseWorldDepth, - -1 - ); - lodManager.forceUpdate(); - } else { - applyActiveLodIndex(0); - } - - updateView(true); - } - - /** - * Apply the given LOD index from the current processed pyramid. - * Invoked by LodManager when it selects a new level. - */ - private void applyActiveLodIndex(int idx) { - if (lodProcessedLevels == null || lodProcessedLevels.isEmpty()) return; - if (idx < 0) idx = 0; - if (idx >= lodProcessedLevels.size()) idx = lodProcessedLevels.size() - 1; - - HeightField selected = lodProcessedLevels.get(idx); - if (idx == activeLodIndex && activeHeightField == selected) return; - - activeLodIndex = idx; - activeHeightField = selected; - - // xWidth/zWidth continue to describe the currently rendered mesh dimensions, - // while dataGrid/originalGrid retain full source/working data. - xWidth = activeHeightField.width(); - zWidth = activeHeightField.height(); - - currentLodSurfScaleX = baseWorldWidth / xWidth; - currentLodSurfScaleZ = baseWorldDepth / zWidth; - - System.out.println("Applying Hypersurface LOD L" + activeLodIndex - + ": " + xWidth + "x" + zWidth - + ", cellScale=" + currentLodSurfScaleX + "x" + currentLodSurfScaleZ); - - if (surfPlot != null) { - surfPlot.setTranslateX(-baseWorldWidth / 2.0); - surfPlot.setTranslateZ(-baseWorldDepth / 2.0); - } - - syncGuiControls(); - updateTheMesh(); - } - - /** - * Build a dense similarity/divergence row for a node from the current sparse graph. - */ - private Double[] buildSimilarityRowFromGraph(GraphNode node) { - if (currentGraph == null || node == null) return new Double[0]; - - final List nodes = currentGraph.getNodes(); - int n = nodes.size(); - double[] row = new double[n]; - - // map: GraphNode -> index (by reference) - // using indexOf is okay for typical sizes; can optimize later with a HashMap - for (GraphEdge e : currentGraph.getEdges()) { - // outgoing (node -> other) - if (currentGraph.findNodeById(e.getStartID()).filter(node::equals).isPresent()) { - currentGraph.findNodeById(e.getEndID()).ifPresent(other -> { - int j = nodes.indexOf(other); - if (j >= 0) { - double w = getEdgeWeightSafe(e); - row[j] = w; - } - }); - } - // incoming (other -> node) — include if you want undirected row - if (currentGraph.findNodeById(e.getEndID()).filter(node::equals).isPresent()) { - currentGraph.findNodeById(e.getStartID()).ifPresent(other -> { - int j = nodes.indexOf(other); - if (j >= 0) { - double w = getEdgeWeightSafe(e); - // for undirected behavior, you can take max/avg; here we keep the larger magnitude - row[j] = Math.abs(row[j]) >= Math.abs(w) ? row[j] : w; - } - }); - } - } - - Double[] out = new Double[n]; - for (int i = 0; i < n; i++) out[i] = row[i]; - return out; - } - - /** - * Prefer GraphEdge#getWeight(); fallback to 1.0 if unavailable. - */ - private double getEdgeWeightSafe(GraphEdge e) { - try { - // Adjust if your API uses a different accessor - return (double) GraphEdge.class.getMethod("getWeight").invoke(e); - } catch (Throwable t) { - return 1.0; // default if weight not present - } - } - - /** - * Optional: if a GraphNode encodes a source row index for the hypersurface, try to extract it. - */ - private Optional tryGetSourceRowIndex(GraphNode node) { - // Heuristics: - // (a) if there's a "getIndex()" method - try { - Object idx = GraphNode.class.getMethod("getIndex").invoke(node); - if (idx instanceof Number n) return Optional.of(n.intValue()); - } catch (Throwable ignored) { - } - // (b) if ID is numeric - try { - Object id = GraphNode.class.getMethod("getId").invoke(node); - if (id != null) return Optional.of(Integer.valueOf(String.valueOf(id))); - } catch (Throwable ignored) { - } - return Optional.empty(); - } - - /** - * Highlight a row on the hypersurface if we can map a node to a data row. - */ - private void highlightSurfaceRowIfPossible(GraphNode node) { - tryGetSourceRowIndex(node).ifPresent(rowIndex -> { - // Clamp row and paint crosshair - int clamped = Math.max(0, Math.min(rowIndex, Math.max(0, getSourceHeight() - 1))); - paintSingleColor(Color.TRANSPARENT); - // Source row coordinates remain stable even when the active render LOD changes. - Point3D center = new Point3D(getWorldWidth() / 2.0, 0, clamped * surfScale); - illuminateCrosshair(center); - }); - } -} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/RetroWavePane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/RetroWavePane.java index c99373a2..0b127784 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/RetroWavePane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/RetroWavePane.java @@ -1,6 +1,7 @@ package edu.jhuapl.trinity.javafx.javafx3d; import edu.jhuapl.trinity.data.messages.bci.ChannelFrame; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedBox; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/TessellationMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/TessellationMesh.java index 786ebb21..a13face2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/TessellationMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/TessellationMesh.java @@ -1,7 +1,7 @@ package edu.jhuapl.trinity.javafx.javafx3d.animated; import edu.jhuapl.trinity.javafx.components.MatrixEffect; -import edu.jhuapl.trinity.javafx.javafx3d.HyperSurfacePlotMesh; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.javafx.javafx3d.Vert3D; import javafx.animation.AnimationTimer; import javafx.beans.property.SimpleBooleanProperty; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HeightField.java similarity index 98% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HeightField.java index 5df03103..34a6428f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HeightField.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import java.util.Arrays; import java.util.Objects; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HyperSurfacePlotMesh.java similarity index 98% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HyperSurfacePlotMesh.java index 913092ba..4acebd99 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HyperSurfacePlotMesh.java @@ -28,7 +28,9 @@ */ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.javafx.javafx3d.Vert3D; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; @@ -40,7 +42,6 @@ import javafx.geometry.Point2D; import javafx.scene.DepthTest; import javafx.scene.image.Image; -import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; import javafx.scene.paint.Color; import javafx.scene.paint.PhongMaterial; @@ -359,7 +360,7 @@ public void setDirectTextureModeByHeight(int colors, double min, double max) { setMaterial(material); } - protected final void updateMeshSmooth(int rangeX, int rangeY) { + public final void updateMeshSmooth(int rangeX, int rangeY) { clearDirectHeightFieldState(); setMesh(null); mesh = createSmoothMesh(getFunctionVert3D(), @@ -708,16 +709,7 @@ private WritableImage getOrCreateDirectPalette(int colors) { return directPaletteImage; } - WritableImage palette = new WritableImage(colors, 1); - PixelWriter writer = palette.getPixelWriter(); - for (int i = 0; i < colors; i++) { - double d = (double) i / (double) (colors - 1); - Color c; - if (i == 0) c = Color.BLACK; - else if (i == colors - 1) c = Color.WHITE; - else c = Color.hsb(360.0 * d, 1.0, 1.0, 1.0); - writer.setColor(i, 0, c); - } + WritableImage palette = SurfaceColorPalette.createPaletteImage(colors); directPaletteImage = palette; directPaletteColors = colors; return palette; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java new file mode 100644 index 00000000..9e339d30 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -0,0 +1,1715 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.App; +import edu.jhuapl.trinity.css.StyleResourceProvider; +import edu.jhuapl.trinity.data.CoordinateSet; +import edu.jhuapl.trinity.data.files.FeatureCollectionFile; +import edu.jhuapl.trinity.data.messages.bci.SemanticMap; +import edu.jhuapl.trinity.data.messages.bci.SemanticMapCollection; +import edu.jhuapl.trinity.data.messages.bci.SemanticReconstruction; +import edu.jhuapl.trinity.data.messages.bci.SemanticReconstructionMap; +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.data.messages.xai.ShapleyCollection; +import edu.jhuapl.trinity.data.messages.xai.ShapleyVector; +import edu.jhuapl.trinity.javafx.components.panes.SurfaceChartPane; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceGridEvent; +import edu.jhuapl.trinity.javafx.events.ImageEvent; +import edu.jhuapl.trinity.javafx.events.ManifoldEvent; +import edu.jhuapl.trinity.javafx.events.ShadowEvent; +import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.ExMaxClusterTask; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.HDDBSCANClusterTask; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMeansClusterTask; +import edu.jhuapl.trinity.javafx.javafx3d.tasks.KMediodsClusterTask; +import edu.jhuapl.trinity.javafx.renderers.FeatureVectorRenderer; +import edu.jhuapl.trinity.javafx.renderers.SemanticMapRenderer; +import edu.jhuapl.trinity.javafx.renderers.ShapleyVectorRenderer; +import edu.jhuapl.trinity.utils.DataUtils.HeightMode; +import edu.jhuapl.trinity.utils.ResourceUtils; +import edu.jhuapl.trinity.utils.Utils; +import edu.jhuapl.trinity.utils.metric.Metric; +import edu.jhuapl.trinity.utils.statistics.GridDensityResult; +import javafx.animation.KeyFrame; +import javafx.animation.KeyValue; +import javafx.animation.Timeline; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.embed.swing.SwingFXUtils; +import javafx.event.ActionEvent; +import javafx.event.Event; +import javafx.scene.AmbientLight; +import javafx.scene.Group; +import javafx.scene.PerspectiveCamera; +import javafx.scene.PointLight; +import javafx.scene.Scene; +import javafx.scene.SceneAntialiasing; +import javafx.scene.SnapshotParameters; +import javafx.scene.SubScene; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.CheckMenuItem; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.DialogPane; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuItem; +import javafx.scene.effect.Glow; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.image.PixelReader; +import javafx.scene.image.WritableImage; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.input.KeyCode; +import javafx.scene.input.MouseButton; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Background; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.DrawMode; +import javafx.scene.text.Font; +import javafx.stage.FileChooser; +import javafx.stage.StageStyle; +import javafx.util.Duration; +import org.fxyz3d.scene.Skybox; +import org.fxyz3d.utils.CameraTransformer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.io.File; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Random; + +/** + * @author Sean Phillips + */ +public class Hypersurface3DPane extends StackPane + implements SemanticMapRenderer, FeatureVectorRenderer, ShapleyVectorRenderer { + + private static final Logger LOG = LoggerFactory.getLogger(Hypersurface3DPane.class); + public static double ICON_FIT_HEIGHT = 64; + public static double DEFAULT_INTRO_DISTANCE = -30000.0; + public static double DEFAULT_ZOOM_TIME_MS = 500.0; + public static double CHIP_FIT_WIDTH = 200; + public static int DEFAULT_XWIDTH = 200; + public static int DEFAULT_ZWIDTH = 200; + public static int DEFAULT_SURFSCALE = 5; + public static int DEFAULT_YSCALE = 5; + private static final double POINT_LIGHT_CAMERA_OFFSET_Z = 100.0; + private static final double POINT_LIGHT_QUADRATIC_ATTENUATION = 0.000001; + + public PerspectiveCamera camera; + public CameraTransformer cameraTransform = new CameraTransformer(); + private final HypersurfaceCameraController cameraController; + + private double cameraDistance = -1000; + private final double sceneWidth = 4000; + private final double sceneHeight = 4000; + private final double planeSize = sceneWidth / 2.0; + + + public Group sceneRoot = new Group(); + public Group extrasGroup; + public SubScene subScene; + + + //feature vector indices for 3D coordinates + private int xFactorIndex = 0; + private int yFactorIndex = 1; + private int zFactorIndex = 2; + private int factorMaxIndex = 512; + + public Color sceneColor = Color.BLACK; + public boolean surfaceRender = true; + + public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} + + COLORATION colorationMethod = COLORATION.COLOR_BY_FEATURE; + private final HypersurfaceSourceModel sourceModel = + new HypersurfaceSourceModel(DEFAULT_XWIDTH, DEFAULT_SURFSCALE); + private final HypersurfaceProcessingController processingController = + new HypersurfaceProcessingController(sourceModel); + private boolean suppressRowOrientationRefresh; + + // Public collection aliases retained for compatibility. The source model owns + // these mutable instances and all internal source-state semantics. + public List shapleyVectors = sourceModel.getShapleyVectors(); + + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay = new SurfaceCrosshairOverlay(); + private final HypersurfaceInteractionController interactionController; + private final HypersurfaceOverlayManager overlayManager; + /** + * Desktop-level legend HUD. Constructed lazily on the JavaFX Application Thread + * because Hypersurface3DPane itself is created by AppAsyncManager on a worker thread. + */ + private HypersurfaceLegendOverlay legendOverlay; + private final BooleanProperty legendEnabled = new SimpleBooleanProperty(true); + + public List featureVectors = sourceModel.getFeatureVectors(); + + /** + * Authoritative boxed working/source grid retained for compatibility with existing + * analysis and ingestion paths. LOD selection must never replace this with a + * downsampled render grid; sourceModel.getActiveHeightField() owns render-side data. + */ + public List> dataGrid = sourceModel.getDataGrid(); + + private Random rando = new Random(); + public HyperSurfacePlotMesh surfPlot; + + public int xWidth = DEFAULT_XWIDTH; + public int zWidth = DEFAULT_ZWIDTH; + public float yScale = DEFAULT_YSCALE; + public float surfScale = DEFAULT_SURFSCALE; + + private Skybox skybox; + + BorderPane bp; + + public List featureLabels = sourceModel.getFeatureLabels(); + public Scene scene; + public String imageryBasePath = ""; + SurfaceChartPane surfaceChartPane; + public AmbientLight ambientLight; + public PointLight pointLight; + private AmbientLight skyboxAmbientLight; + + // ============================================================ + // Surface renderer / LOD orchestration + // ============================================================ + + /** + * True only while model state is being pushed into HypersurfaceControlsPane. + * GUI control listeners echo their changes back as HypersurfaceEvents, so those + * echoes must not be interpreted as user-requested geometry rebuilds. + */ + private boolean syncingGuiControls = false; + + private final SurfaceCoordinateMapper coordinateMapper = new SurfaceCoordinateMapper(); + + private final HypersurfaceRenderController renderController; + + // Temporary graph subsystem hosted by Hypersurface until it is migrated to + // a dedicated graph 3D pane/SubScene. + private final HypersurfaceGraphController graphController; + + public Hypersurface3DPane(Scene scene) { + this.scene = scene; + configureLegendLifecycle(); + ambientLight = new AmbientLight(Color.WHITE); + + setBackground(Background.EMPTY); + subScene = new SubScene(sceneRoot, sceneWidth, sceneHeight, true, SceneAntialiasing.BALANCED); + subScene.widthProperty().bind(widthProperty()); + subScene.heightProperty().bind(heightProperty()); + subScene.setFill(sceneColor); + + overlayManager = new HypersurfaceOverlayManager( + this, scene, subScene, coordinateMapper, planeSize); + extrasGroup = overlayManager.getExtrasGroup(); + interactionController = new HypersurfaceInteractionController( + this, scene, coordinateMapper, surfaceCrosshairOverlay, overlayManager); + graphController = new HypersurfaceGraphController(scene, interactionController::setGraph); + + camera = new PerspectiveCamera(true); + cameraTransform.setTranslate(0, 0, 0); + cameraTransform.getChildren().add(camera); + camera.setNearClip(0.1); + camera.setFarClip(100000.0); + camera.setTranslateZ(cameraDistance); + cameraTransform.ry.setAngle(-45.0); + cameraTransform.rx.setAngle(-10.0); + + cameraController = new HypersurfaceCameraController( + camera, + cameraTransform, + subScene, + this::getCameraSurfaceBounds, + this::handleCameraInteraction, + this::handleCameraSettled); + renderController = new HypersurfaceRenderController( + this, sourceModel, processingController, surfaceCrosshairOverlay, this::refreshCoordinateMapper); + + configureSourceModelListeners(); + cameraController.installInputHandlers(); + configureSceneGraph(); + configureKeyboardHandling(); + configurePaneLayout(); + configureContextMenu(); + + graphController.installEventHandlers(); + loadSurf3D(); + registerSceneEventHandlers(); + } + + private void configureLegendLifecycle() { + visibleProperty().addListener((obs, oldValue, newValue) -> refreshLegendVisibility()); + // AppAsyncManager constructs this pane on a worker thread, but the Node is + // attached to centerStack later on the JavaFX Application Thread. Defer all + // desktop-overlay creation/attachment until that scene-attachment point. + sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene != null) attachLegendOverlay(App.getAppPathPaneStack()); + }); + } + + private void configureSourceModelListeners() { + sourceModel.rowOrientationProperty().addListener((obs, oldValue, newValue) -> { + renderController.setRowOrientation(newValue); + refreshCoordinateMapper(); + if (!suppressRowOrientationRefresh && surfPlot != null) { + updateTheMesh(); + } + }); + sourceModel.heightOrientationProperty().addListener((obs, oldValue, newValue) -> { + renderController.setHeightOrientation(newValue); + refreshCrosshairSurfaceContext(); + refreshLegendOverlay(); + if (surfPlot != null) { + updateTheMesh(); + } + }); + } + + private void configureSceneGraph() { + setupSkyBox(); + overlayManager.setInitialVisibility(false); + sceneRoot.getChildren().addAll(cameraTransform, overlayManager.getHighlightedPoint(), + overlayManager.getNodeGroup(), extrasGroup); + + // The graph is still temporarily hosted by Hypersurface, but its visual + // layer/state is owned by HypersurfaceGraphController so it can later migrate + // to a dedicated graph 3D pane/SubScene. + sceneRoot.getChildren().add(graphController.getGraphLayer()); + sceneRoot.getChildren().add(surfaceCrosshairOverlay); + graphController.syncVisibilityGui(); + subScene.setCamera(camera); + + pointLight = new PointLight(Color.WHITE); + cameraTransform.getChildren().add(pointLight); + pointLight.translateXProperty().bind(camera.translateXProperty()); + pointLight.translateYProperty().bind(camera.translateYProperty()); + pointLight.translateZProperty().bind( + camera.translateZProperty().add(POINT_LIGHT_CAMERA_OFFSET_Z)); + pointLight.setConstantAttenuation(1.0); + pointLight.setLinearAttenuation(0.0); + pointLight.setQuadraticAttenuation(POINT_LIGHT_QUADRATIC_ATTENUATION); + } + + private void configureKeyboardHandling() { + subScene.setOnKeyPressed(event -> { + KeyCode keycode = event.getCode(); + boolean cameraHandled = cameraController.handleKeyPressed(event); + + if (keycode == KeyCode.COMMA) { + if (xFactorIndex > 0 && yFactorIndex > 0 && zFactorIndex > 0) { + xFactorIndex -= 1; + yFactorIndex -= 1; + zFactorIndex -= 1; + Platform.runLater(() -> scene.getRoot().fireEvent( + new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, + new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); + notifyIndexChange(); + overlayManager.updateLabels(); + } + } + if (keycode == KeyCode.PERIOD) { + int featureSize = featureVectors.isEmpty() + ? factorMaxIndex + : featureVectors.get(0).getData().size(); + if (xFactorIndex < factorMaxIndex - 1 && yFactorIndex < factorMaxIndex - 1 + && zFactorIndex < factorMaxIndex - 1 && xFactorIndex < featureSize - 1 + && yFactorIndex < featureSize - 1 && zFactorIndex < featureSize - 1) { + xFactorIndex += 1; + yFactorIndex += 1; + zFactorIndex += 1; + Platform.runLater(() -> scene.getRoot().fireEvent( + new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, + new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); + notifyIndexChange(); + overlayManager.updateLabels(); + } else { + scene.getRoot().fireEvent(new CommandTerminalEvent("Feature Index Max Reached: (" + + featureSize + ")", new Font("Consolas", 20), Color.YELLOW)); + } + } + if (keycode == KeyCode.Y) renderController.scaleHeight(1.1); + if (keycode == KeyCode.H) renderController.scaleHeight(0.9); + + if (keycode == KeyCode.I) { + double tz = event.isShiftDown() ? 50 : 5; + overlayManager.moveTimeline(tz); + } + if (keycode == KeyCode.K) { + double tz = event.isShiftDown() ? 50 : 5; + overlayManager.moveTimeline(-tz); + } + + if (!cameraHandled) { + overlayManager.refreshProjectedOverlays(); + requestLodUpdate(LodManager.UpdateReason.OTHER); + } + }); + } + + private void configurePaneLayout() { + bp = new BorderPane(subScene); + getChildren().clear(); + getChildren().addAll(bp, overlayManager.getLabelGroup()); + } + + private void configureContextMenu() { + MenuItem showControlsItem = new MenuItem("Hypersurface Controls"); + showControlsItem.setOnAction(e -> scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE))); + + MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); + copyAsImageItem.setOnAction((ActionEvent e) -> { + Clipboard clipboard = Clipboard.getSystemClipboard(); + ClipboardContent content = new ClipboardContent(); + content.putImage(this.snapshot(new SnapshotParameters(), null)); + clipboard.setContent(content); + }); + + MenuItem saveSnapshotItem = new MenuItem("Save Scene as Image"); + saveSnapshotItem.setOnAction((ActionEvent e) -> { + final FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Save scene as..."); + fileChooser.setInitialFileName("trinity_hypersurface.png"); + fileChooser.setInitialDirectory(Paths.get(".").toFile()); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("PNG", "*.png")); + File file = fileChooser.showSaveDialog(null); + if (file != null) { + WritableImage image = this.snapshot(new SnapshotParameters(), null); + try { + ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", file); + } catch (IOException ioe) { + LOG.error("Unable to save Hypersurface snapshot.", ioe); + } + } + }); + + MenuItem unrollHyperspaceItem = new MenuItem("Unroll Hyperspace Data"); + unrollHyperspaceItem.setOnAction(e -> unrollHyperspace()); + + MenuItem vectorDistanceItem = new MenuItem("Show Vector Distances"); + vectorDistanceItem.setOnAction(e -> computeVectorDistances()); + + MenuItem collectionDifferenceItem = new MenuItem("Feature Collection Difference"); + collectionDifferenceItem.setOnAction(e -> { + final FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Load FeatureCollection to Compare..."); + fileChooser.setInitialDirectory(Paths.get(".").toFile()); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); + File file = fileChooser.showOpenDialog(null); + if (file != null) { + try { + FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); + computeSurfaceDifference(fcf.featureCollection); + } catch (IOException ex) { + LOG.error(null, ex); + } + } + }); + + MenuItem cosineSimilarityItem = new MenuItem("Feature Collection Cosine Distance"); + cosineSimilarityItem.setOnAction(e -> { + final FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle("Load FeatureCollection to Compare..."); + fileChooser.setInitialDirectory(Paths.get(".").toFile()); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("JSON", "*.json")); + File file = fileChooser.showOpenDialog(null); + if (file != null) { + try { + FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); + computeCosineDistance(fcf.featureCollection); + } catch (IOException ex) { + LOG.error(null, ex); + } + } + }); + + Glow glow = new Glow(0.5); + ImageView analysisImageView = ResourceUtils.loadIcon("analysis", ICON_FIT_HEIGHT); + analysisImageView.setEffect(glow); + Menu analysisMenu = new Menu("Analysis", analysisImageView, + vectorDistanceItem, collectionDifferenceItem, cosineSimilarityItem); + + CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); + enableHoverItem.setOnAction(e -> + interactionController.setHoverEnabled(enableHoverItem.isSelected())); + + CheckMenuItem surfaceChartsItem = new CheckMenuItem("Surface Charts"); + surfaceChartsItem.setOnAction(e -> + interactionController.setSurfaceChartsEnabled(surfaceChartsItem.isSelected())); + + MenuItem navigatorItem = new MenuItem("Content Navigator"); + navigatorItem.setOnAction(e -> interactionController.showContentNavigator( + sourceModel.isImageBackedSurface() ? sourceModel.getSourceImage() : null)); + + MenuItem updateAllItem = new MenuItem("Update Render"); + updateAllItem.setOnAction(e -> updateAll()); + + MenuItem clearDataItem = new MenuItem("Clear Data"); + clearDataItem.setOnAction(e -> { + clearAll(); + xWidth = DEFAULT_XWIDTH; + zWidth = DEFAULT_ZWIDTH; + syncGuiControls(); + generateRandos(xWidth, zWidth, yScale); + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); + }); + + CheckMenuItem showDataMarkersItem = new CheckMenuItem("Show Data Markers"); + showDataMarkersItem.setOnAction(e -> + overlayManager.setDataMarkersVisible(showDataMarkersItem.isSelected())); + + CheckMenuItem enableCrosshairsItem = new CheckMenuItem("Enable Crosshairs"); + enableCrosshairsItem.setOnAction(e -> + interactionController.setCrosshairsEnabled(enableCrosshairsItem.isSelected())); + + MenuItem resetViewItem = new MenuItem("Reset View"); + resetViewItem.setOnAction(e -> resetView(1000, false)); + + ContextMenu cm = new ContextMenu(showControlsItem, navigatorItem, + copyAsImageItem, saveSnapshotItem, unrollHyperspaceItem, analysisMenu, + enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, + updateAllItem, clearDataItem, resetViewItem); + cm.setAutoFix(true); + cm.setAutoHide(true); + cm.setHideOnEscape(true); + cm.setOpacity(0.85); + + subScene.setOnMouseClicked((MouseEvent e) -> { + if (e.getButton() == MouseButton.SECONDARY) { + if (!cm.isShowing()) cm.show(this.getParent(), e.getScreenX(), e.getScreenY()); + else cm.hide(); + e.consume(); + } + }); + } + + private void registerSceneEventHandlers() { + scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { + Color color = (Color) e.object; + subScene.setFill(color); + }); + scene.addEventHandler(HyperspaceEvent.ENABLE_HYPERSPACE_SKYBOX, e -> + skybox.setVisible((Boolean) e.object)); + scene.addEventHandler(ImageEvent.NEW_TEXTURE_SURFACE, e -> handleNewTextureSurface((Image) e.object)); + + scene.addEventHandler(HyperspaceEvent.FACTOR_COORDINATES_GUI, e -> { + CoordinateSet coords = (CoordinateSet) e.object; + xFactorIndex = coords.coordinateIndices.get(0); + yFactorIndex = coords.coordinateIndices.get(1); + zFactorIndex = coords.coordinateIndices.get(2); + overlayManager.updateLabels(); + notifyIndexChange(); + }); + + scene.addEventHandler(HyperspaceEvent.FACTOR_VECTORMAX_GUI, e -> { + int newFactorMaxIndex = (int) e.object; + if (newFactorMaxIndex < factorMaxIndex) { + factorMaxIndex = newFactorMaxIndex; + boolean update = false; + if (xFactorIndex > factorMaxIndex) { + xFactorIndex = factorMaxIndex; + update = true; + } + if (yFactorIndex > factorMaxIndex) { + yFactorIndex = factorMaxIndex; + update = true; + } + if (zFactorIndex > factorMaxIndex) { + zFactorIndex = factorMaxIndex; + update = true; + } + if (update) notifyIndexChange(); + } else { + factorMaxIndex = newFactorMaxIndex; + } + }); + + scene.addEventHandler(HypersurfaceGridEvent.RENDER_PDF, e -> { + applySurfaceGridToHypersurface(e.getZGrid()); + e.consume(); + }); + scene.addEventHandler(HypersurfaceGridEvent.RENDER_CDF, e -> { + applySurfaceGridToHypersurface(e.getZGrid()); + e.consume(); + }); + + scene.addEventHandler(ShadowEvent.SHOW_AXES_LABELS, e -> + overlayManager.setAxesAndLabelsVisible((boolean) e.object)); + scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, + e -> imageryBasePath = (String) e.object); + + Platform.runLater(() -> { + overlayManager.updateLabels(); + updateTheMesh(); + }); + } + + private void handleNewTextureSurface(Image image) { + int x1 = 0; + int y1 = 0; + int x2 = (int) image.getWidth(); + int y2 = (int) image.getHeight(); + if (x2 > 512 || y2 > 512) { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION, + "Image has " + x2 + " rows and " + y2 + " columns.\n" + + "Split the image before tessellation?", + ButtonType.YES, ButtonType.NO, ButtonType.CANCEL); + alert.setTitle("Image Tessellation Import"); + alert.setHeaderText("Image has " + x2 + " rows and " + y2 + " columns.\n"); + alert.setContentText("Select subregion from image before tessellation?"); + alert.setGraphic(ResourceUtils.loadIcon("alert", 75)); + alert.initStyle(StageStyle.TRANSPARENT); + DialogPane dialogPane = alert.getDialogPane(); + dialogPane.setBackground(Background.EMPTY); + dialogPane.getScene().setFill(Color.TRANSPARENT); + String dialogCss = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); + dialogPane.getStylesheets().add(dialogCss); + + Optional result = alert.showAndWait(); + if (result.isEmpty() || result.get().equals(ButtonType.CANCEL)) return; + if (result.get().equals(ButtonType.YES)) { + scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_PIXEL_SELECTION, image)); + return; + } + } + tessellateImage(image, x1, y1, x2, y2); + } + + public void computeCosineDistance(FeatureCollection collection) { + double[][] newRayRay = collection.convertFeaturesToArray(); + Metric metric = Metric.getMetric("cosine"); + List cosineDistancesGrid = new ArrayList<>(); + for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { + double[] dataGridVector = dataGrid.get(rowIndex).stream().mapToDouble(Double::doubleValue).toArray(); + double currentDistance = metric.distance(dataGridVector, newRayRay[rowIndex]); + cosineDistancesGrid.add(currentDistance); + } + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, "Feature Collection Cosine Similarity", + cosineDistancesGrid.toArray(Double[]::new))); + LOG.info("{}", cosineDistancesGrid); + } + + private void applySurfaceGridToHypersurface(List> grid) { + double userScale = 1.0; // future: user control + List> scaled = processingController.normalizeAndScale(grid, userScale); + dataGrid.clear(); + dataGrid.addAll(scaled); + captureDataGridAsSource(); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW: run pipeline + } + + public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean flipY) { + List> grid = useCDF ? res.cdfAsListGrid() : res.pdfAsListGrid(); + if (flipY) Collections.reverse(grid); + dataGrid.clear(); + dataGrid.addAll(grid); + captureDataGridAsSource(); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW + } + + public void computeSurfaceDifference(FeatureCollection collection) { + double[][] newRayRay = collection.convertFeaturesToArray(); + List> differencesGrid = new ArrayList<>(); + for (int rowIndex = 0; rowIndex < dataGrid.size(); rowIndex++) { + List differenceVector = new ArrayList<>(); + List currentRow = dataGrid.get(rowIndex); + int width = currentRow.size(); + for (int colIndex = 0; colIndex < width; colIndex++) { + if (rowIndex < newRayRay.length && colIndex < newRayRay[rowIndex].length) { + differenceVector.add(currentRow.get(colIndex) - newRayRay[rowIndex][colIndex]); + } else differenceVector.add(0.0); + } + differencesGrid.add(differenceVector); + } + dataGrid.clear(); + dataGrid.addAll(differencesGrid); + captureDataGridAsSource(); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW + } + + public void computeVectorDistances() { + Metric metric = Metric.getMetric("cosine"); + List> distancesGrid = new ArrayList<>(); + dataGrid.stream().forEach(row -> { + double[] rowVector = row.stream().mapToDouble(Double::doubleValue).toArray(); + List distanceVector = new ArrayList<>(); + for (int i = 0; i < dataGrid.size(); i++) { + double[] xVector = dataGrid.get(i).stream().mapToDouble(Double::doubleValue).toArray(); + double currentDistance = metric.distance(xVector, rowVector); + distanceVector.add(currentDistance); + } + distancesGrid.add(distanceVector); + }); + dataGrid.clear(); + dataGrid.addAll(distancesGrid); + captureDataGridAsSource(); + xWidth = dataGrid.get(0).size(); + zWidth = dataGrid.size(); + syncGuiControls(); + rebuildProcessedGridAndRefresh(); // NEW + } + + public void unrollHyperspace() { + getScene().getRoot().fireEvent(new CommandTerminalEvent("Requesting Hyperspace Vectors...", + new Font("Consolas", 20), Color.GREEN)); + getScene().getRoot().fireEvent(new FeatureVectorEvent(FeatureVectorEvent.REQUEST_FEATURE_COLLECTION)); + } + + public void updateTheMesh() { + renderController.updateMesh(); + } + + private void applyCurrentColoration() { + renderController.applyCurrentColoration(); + } + + private void setupSkyBox() { + Image top = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); + Image bottom = new Image(ImageResourceProvider.getResource("darkmetalbottom.png").toExternalForm()); + Image left = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image right = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image front = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + Image back = new Image(ImageResourceProvider.getResource("1500_blackgrid.png").toExternalForm()); + double size = 100000D; + skybox = new Skybox(top, bottom, left, right, front, back, size, camera); + sceneRoot.getChildren().add(skybox); + skyboxAmbientLight = new AmbientLight(Color.WHITE); + skyboxAmbientLight.getScope().add(skybox); + sceneRoot.getChildren().add(skyboxAmbientLight); + skybox.setVisible(false); + } + + private void notifyIndexChange() { + getScene().getRoot().fireEvent(new CommandTerminalEvent("X,Y,Z Indices = (" + + xFactorIndex + ", " + yFactorIndex + ", " + zFactorIndex + ")", + new Font("Consolas", 20), Color.GREEN)); + } + + public void resetView(double milliseconds, boolean rightNow) { + cameraController.reset(rightNow ? 0.0 : milliseconds); + } + + /** + * Fits the complete current surface while preserving the current view orientation. + */ + public void fitCameraView(double milliseconds) { + cameraController.fit(milliseconds); + } + + /** + * Applies a canonical camera orientation and fits the complete current surface. + */ + public void applyCameraPreset(HypersurfaceCameraController.Preset preset, double milliseconds) { + cameraController.applyPreset(preset, milliseconds); + } + + public void intro(double milliseconds) { + cameraController.intro(milliseconds, DEFAULT_INTRO_DISTANCE); + } + + public void outtro(double milliseconds) { + cameraController.outtro(milliseconds, DEFAULT_INTRO_DISTANCE); + } + + public void updateAll() { + if (Platform.isFxApplicationThread()) { + updateTheMesh(); + } else { + Platform.runLater(this::updateTheMesh); + } + } + + private void handleCameraInteraction(HypersurfaceCameraController.InteractionType interactionType) { + overlayManager.refreshProjectedOverlays(); + LodManager.UpdateReason reason = switch (interactionType) { + case DRAG -> LodManager.UpdateReason.DRAG; + case ZOOM -> LodManager.UpdateReason.SCROLL; + case KEYBOARD -> LodManager.UpdateReason.OTHER; + }; + requestLodUpdate(reason); + } + + private void handleCameraSettled() { + overlayManager.refreshProjectedOverlays(); + forceLodUpdate(); + } + + HeightField getActiveHeightFieldForInteraction() { + return sourceModel.getActiveHeightField(); + } + + List getFeatureVectorsForInteraction() { + return featureVectors; + } + + List getFeatureLabelsForInteraction() { + return featureLabels; + } + + TiledSurfaceRenderer getTiledSurfaceRendererForInteraction() { + return renderController.getTiledSurfaceRenderer(); + } + + Image getSourceImageForInteraction() { + return sourceModel.getSourceImage(); + } + + int getImageSourceStartXForInteraction() { + return sourceModel.getImageSourceStartX(); + } + + int getImageSourceStartYForInteraction() { + return sourceModel.getImageSourceStartY(); + } + + void showSurfaceChartsPane() { + Pane pathPane = App.getAppPathPaneStack(); + if (surfaceChartPane == null) { + surfaceChartPane = new SurfaceChartPane(scene, pathPane); + surfaceChartPane.visibleProperty().bind(visibleProperty()); + } + if (!pathPane.getChildren().contains(surfaceChartPane)) { + pathPane.getChildren().add(surfaceChartPane); + surfaceChartPane.slideInPane(); + } else { + surfaceChartPane.show(); + } + } + + private void generateRandos(int xWidth, int zWidth, float yScale) { + dataGrid.clear(); + List xList; + for (int z = 0; z < zWidth; z++) { + xList = new ArrayList<>(xWidth); + for (int x = 0; x < xWidth; x++) xList.add(rando.nextDouble() * yScale); + dataGrid.add(xList); + } + } + + private void configureLightingScopes() { + // Keep the camera-following point light focused on the analytical surface/graph. + pointLight.getScope().setAll( + surfPlot, renderController.getTiledSurfaceRenderer(), graphController.getGraphLayer()); + + // Ambient lighting also owns the auxiliary 3D markers. Because this light uses + // explicit scope, omitting these groups leaves their ordinary PhongMaterials + // effectively unlit (for example the X/Y/Z axis spheres and timeline markers). + ambientLight.getScope().setAll( + surfPlot, + renderController.getTiledSurfaceRenderer(), + graphController.getGraphLayer(), + overlayManager.getNodeGroup(), + extrasGroup, + overlayManager.getHighlightedPoint(), + surfaceCrosshairOverlay); + } + + private void loadSurf3D() { + LOG.info("Rendering Hypersurface Mesh..."); + generateRandos(xWidth, zWidth, yScale); + captureDataGridAsSource(); + + surfPlot = renderController.initializeRenderers(); + interactionController.installSurfacePicking( + surfPlot, renderController.getTiledSurfaceRenderer()); + + // Build processed pyramid and apply initial LOD. The primitive source cache is + // created by rebuildProcessedGridAndRefresh() from the current source snapshot. + rebuildProcessedGridAndRefresh(); + + overlayManager.initializeTimelineMarkers(); + + wireEventHandlers(); + + configureLightingScopes(); + sceneRoot.getChildren().add(ambientLight); + + overlayManager.updateLabels(); + } + + /** + * Fires HypersurfaceEvent GUI sync events for all core geometry controls + * (xWidth, zWidth, yScale, surfScale) to synchronize GUI controls with model state. + */ + public void syncGuiControls() { + // SET_*_GUI events update Spinner values synchronously. Those Spinner value + // listeners echo XWIDTH_CHANGED/ZWIDTH_CHANGED/etc. back through the Scene. + // Mark the entire model -> GUI synchronization window so those echoes do not + // trigger geometry rebuilds. + syncingGuiControls = true; + try { + fireOnRoot(HypersurfaceEvent.setXWidthGUI(xWidth)); + fireOnRoot(HypersurfaceEvent.setZWidthGUI(zWidth)); + fireOnRoot(HypersurfaceEvent.setYScaleGUI(yScale)); + fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); + } finally { + syncingGuiControls = false; + } + } + + /** + * Helper to fire on the JavaFX root, or self as fallback (copy this if not already present) + */ + private void fireOnRoot(Event evt) { + if (scene != null && scene.getRoot() != null) { + scene.getRoot().fireEvent(evt); + } else { + this.fireEvent(evt); + } + } + + /** + * Sets up event handlers for HypersurfaceEvents sent from HypersurfaceControlsPane. + * Updates all rendering state and triggers updates as needed. + */ + private void wireEventHandlers() { + if (scene == null) return; + // Geometry / scale + scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; + this.xWidth = (int) e.object; + renderController.updateRawSurfaceTranslation(); + refreshCoordinateMapper(); + updateTheMesh(); + }); + + scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; + this.zWidth = (int) e.object; + renderController.updateRawSurfaceTranslation(); + refreshCoordinateMapper(); + updateTheMesh(); + }); + + scene.addEventHandler(HypersurfaceEvent.Y_SCALE_CHANGED, e -> { + if (syncingGuiControls) return; + this.yScale = ((Double) e.object).floatValue(); + renderController.setFunctionScale(yScale); + refreshCrosshairSurfaceContext(); + refreshLegendOverlay(); + updateTheMesh(); + }); + + scene.addEventHandler(HypersurfaceEvent.SURF_SCALE_CHANGED, e -> { + if (syncingGuiControls) return; + this.surfScale = ((Double) e.object).floatValue(); + refreshWorldExtentsAndLodMetadata(); + updateTheMesh(); + }); + + // Rendering modes + scene.addEventHandler(HypersurfaceEvent.SURFACE_RENDER_CHANGED, e -> { + this.surfaceRender = (boolean) e.object; + updateTheMesh(); + }); + scene.addEventHandler(HypersurfaceEvent.DRAW_MODE_CHANGED, e -> { + renderController.setDrawMode((DrawMode) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.CULL_FACE_CHANGED, e -> { + renderController.setCullFace((CullFace) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { + COLORATION previous = this.colorationMethod; + COLORATION next = (Hypersurface3DPane.COLORATION) e.object; + this.colorationMethod = next; + renderController.handleColorationChanged(previous, next); + refreshLegendOverlay(); + }); + + // Processing pipeline + scene.addEventHandler(HypersurfaceEvent.HEIGHT_MODE_CHANGED, e -> { + processingController.setHeightMode((HeightMode) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_ENABLE_CHANGED, e -> { + processingController.setSmoothingEnabled((boolean) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_METHOD_CHANGED, e -> { + processingController.setSmoothingMethod((SurfaceUtils.Smoothing) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.SMOOTHING_RADIUS_CHANGED, e -> { + processingController.setSmoothingRadius((int) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.GAUSSIAN_SIGMA_CHANGED, e -> { + processingController.setGaussianSigma((double) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.INTERP_MODE_CHANGED, e -> { + processingController.setInterpolationMode((SurfaceUtils.Interpolation) e.object); + updateTheMesh(); + }); + scene.addEventHandler(HypersurfaceEvent.TONEMAP_ENABLE_CHANGED, e -> { + processingController.setToneEnabled((boolean) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.TONEMAP_OPERATOR_CHANGED, e -> { + processingController.setToneOperator((SurfaceUtils.ToneMap) e.object); + rebuildProcessedGridAndRefresh(); + }); + scene.addEventHandler(HypersurfaceEvent.TONEMAP_PARAM_CHANGED, e -> { + processingController.setToneParam((double) e.object); + rebuildProcessedGridAndRefresh(); + }); + + // Lighting + scene.addEventHandler(HypersurfaceEvent.AMBIENT_ENABLED_CHANGED, e -> { + if (ambientLight != null) ambientLight.setLightOn((boolean) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.AMBIENT_COLOR_CHANGED, e -> { + if (ambientLight != null) ambientLight.setColor((Color) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.POINT_ENABLED_CHANGED, e -> { + if (pointLight != null) pointLight.setLightOn((boolean) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.POINT_COLOR_CHANGED, e -> { + if (pointLight != null) pointLight.setColor((Color) e.object); + }); + scene.addEventHandler(HypersurfaceEvent.SPECULAR_COLOR_CHANGED, e -> { + renderController.setSpecularColor((Color) e.object); + }); + + // UX toggles + scene.addEventHandler(HypersurfaceEvent.HOVER_ENABLE_CHANGED, e -> + interactionController.setHoverEnabled((boolean) e.object)); + scene.addEventHandler(HypersurfaceEvent.SURFACE_CHARTS_ENABLE_CHANGED, e -> + interactionController.setSurfaceChartsEnabled((boolean) e.object)); + scene.addEventHandler(HypersurfaceEvent.DATA_MARKERS_ENABLE_CHANGED, e -> + overlayManager.setExtrasVisible((boolean) e.object)); + scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> + interactionController.setCrosshairsEnabled((boolean) e.object)); + + // Commands/actions + scene.addEventHandler(HypersurfaceEvent.RESET_VIEW, e -> resetView(1000, false)); + scene.addEventHandler(HypersurfaceEvent.UPDATE_RENDER, e -> updateTheMesh()); + scene.addEventHandler(HypersurfaceEvent.CLEAR_DATA, e -> clearAll()); + scene.addEventHandler(HypersurfaceEvent.UNROLL_REQUESTED, e -> unrollHyperspace()); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_VECTOR_DISTANCES, e -> computeVectorDistances()); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_COLLECTION_DIFF, e -> computeSurfaceDifference((FeatureCollection) e.object)); + scene.addEventHandler(HypersurfaceEvent.COMPUTE_COSINE_DISTANCE, e -> computeCosineDistance((FeatureCollection) e.object)); + } + + private void attachLegendOverlay(Pane desktopPane) { + if (desktopPane == null) return; + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> attachLegendOverlay(desktopPane)); + return; + } + + if (legendOverlay == null) { + legendOverlay = new HypersurfaceLegendOverlay(); + legendOverlay.setMouseTransparent(true); + } + + if (!legendOverlay.layoutXProperty().isBound()) { + legendOverlay.layoutXProperty().bind(Bindings.createDoubleBinding( + () -> Math.max(12.0, + desktopPane.getWidth() - legendOverlay.getWidth() - 24.0), + desktopPane.widthProperty(), + legendOverlay.widthProperty())); + } + if (!legendOverlay.layoutYProperty().isBound()) { + legendOverlay.layoutYProperty().bind(Bindings.createDoubleBinding( + () -> Math.max(12.0, + desktopPane.getHeight() * 0.45 - legendOverlay.getHeight() * 0.5), + desktopPane.heightProperty(), + legendOverlay.heightProperty())); + } + if (!desktopPane.getChildren().contains(legendOverlay)) { + // Keep passive HUD overlays below floating LitPathPane windows. + desktopPane.getChildren().add(0, legendOverlay); + } + refreshLegendVisibility(); + refreshLegendOverlay(); + } + + private HypersurfaceLegendState buildLegendState() { + HeightField rangeField = sourceModel.getLodProcessedLevels() != null && !sourceModel.getLodProcessedLevels().isEmpty() + ? sourceModel.getLodProcessedLevels().get(0) + : sourceModel.getActiveHeightField(); + + double minimum = Double.NaN; + double maximum = Double.NaN; + if (rangeField != null) { + float[] minMax = rangeField.minMax(); + minimum = minMax[0]; + maximum = minMax[1]; + } + + String heightLabel = sourceModel.isImageBackedSurface() ? "Image Intensity" : "Feature Value"; + String colorLabel = switch (colorationMethod) { + case COLOR_BY_IMAGE -> "Source Image"; + case COLOR_BY_FEATURE -> "Feature Value"; + case COLOR_BY_SHAPLEY -> "Shapley Value"; + }; + boolean numericColorRange = colorationMethod == COLORATION.COLOR_BY_FEATURE + && Double.isFinite(minimum) + && Double.isFinite(maximum); + + return new HypersurfaceLegendState( + heightLabel, + minimum, + maximum, + yScale, + getSurfaceHeightOrientation(), + colorationMethod, + colorLabel, + numericColorRange, + minimum, + maximum); + } + + private void refreshLegendOverlay() { + final HypersurfaceLegendState state = buildLegendState(); + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> applyLegendState(state)); + return; + } + applyLegendState(state); + } + + private void applyLegendState(HypersurfaceLegendState state) { + if (legendOverlay != null) legendOverlay.update(state); + } + + private void refreshLegendVisibility() { + final boolean show = isVisible() && legendEnabled.get(); + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> { + if (legendOverlay != null) legendOverlay.setVisible(show); + }); + return; + } + if (legendOverlay != null) legendOverlay.setVisible(show); + } + + public boolean isLegendEnabled() { + return legendEnabled.get(); + } + + public void setLegendEnabled(boolean enabled) { + legendEnabled.set(enabled); + refreshLegendVisibility(); + if (enabled) refreshLegendOverlay(); + } + + public BooleanProperty legendEnabledProperty() { + return legendEnabled; + } + + public void clearAll() { + xFactorIndex = 0; + yFactorIndex = 1; + zFactorIndex = 2; + Platform.runLater(() -> scene.getRoot().fireEvent( + new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, + new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); + notifyIndexChange(); + overlayManager.resetLabelTracking(); + dataGrid.clear(); + featureVectors.clear(); + sourceModel.clearOriginalGrid(); + interactionController.resetSelectionState(); + resetLodDataState(); + } + + public void showAll() { + updateAll(); + } + + public void hideFA3D() { + Timeline timeline = new Timeline( + new KeyFrame(Duration.seconds(0.1), new KeyValue(opacityProperty(), 1.0)), + new KeyFrame(Duration.seconds(0.2), e -> outtro(1000)), + new KeyFrame(Duration.seconds(2.0), new KeyValue(opacityProperty(), 0.0)), + new KeyFrame(Duration.seconds(2.0), e -> setVisible(false)), + new KeyFrame(Duration.seconds(2.1), e -> setOpacity(1.0)) + ); + timeline.setOnFinished(e -> setVisible(false)); + timeline.playFromStart(); + } + + public void showFA3D() { + Timeline timeline = new Timeline( + new KeyFrame(Duration.seconds(0.1), e -> cameraController.setCameraDistance(DEFAULT_INTRO_DISTANCE)), + new KeyFrame(Duration.seconds(0.1), new KeyValue(opacityProperty(), 0.0)), + new KeyFrame(Duration.seconds(0.3), e -> setVisible(true)), + new KeyFrame(Duration.seconds(0.3), new KeyValue(opacityProperty(), 1.0)), + new KeyFrame(Duration.seconds(0.6), e -> intro(1000)) + ); + timeline.playFromStart(); + } + + @Override + public void setFeatureCollection(FeatureCollection fc) { + featureVectors.clear(); + featureVectors.addAll(fc.getFeatures()); + interactionController.resetSelectionState(); + } + + public void findClusters(ManifoldEvent.ProjectionConfig pc) { + if (pc.dataSource != ManifoldEvent.ProjectionConfig.DATA_SOURCE.HYPERSURFACE) return; + double[][] observations = FeatureCollection.toData(featureVectors); + double projectionScalar = 1000.0; + switch (pc.clusterMethod) { + case DBSCAN -> { + DBSCANClusterTask t = new DBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case HDDBSCAN -> { + HDDBSCANClusterTask t = new HDDBSCANClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case KMEANS -> { + KMeansClusterTask t = new KMeansClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case KMEDIODS -> { + KMediodsClusterTask t = new KMediodsClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case EX_MAX -> { + ExMaxClusterTask t = new ExMaxClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + case AFFINITY -> { + AffinityClusterTask t = new AffinityClusterTask(scene, camera, projectionScalar, observations, pc); + if (!t.isCancelledByUser()) { + Thread th = new Thread(t); + th.setDaemon(true); + th.start(); + } + } + } + } + + @Override + public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection) { + SemanticReconstruction reconstruction = semanticMapCollection.getReconstruction(); + SemanticReconstructionMap rMap = reconstruction.getData_vars().getNeural_timeseries(); + List> neuralData = rMap.getData(); + LOG.info("Neural Data dimensions: {} entries at {} frame width.", neuralData.size(), neuralData.get(0).size()); + long startTime = System.nanoTime(); + dataGrid.clear(); + List justTheMags; + for (List phaseMagPairs : neuralData) { + justTheMags = new ArrayList<>(neuralData.get(0).size() / 2); + for (int i = 0; i < phaseMagPairs.size(); i += 2) justTheMags.add(phaseMagPairs.get(i) * yScale); + dataGrid.add(justTheMags); + } + LOG.info("Mapped Neural Magnitudes to Hypersurface: {}", Utils.totalTimeString(startTime)); + zWidth = neuralData.size(); + xWidth = neuralData.get(0).size() / 2; + syncGuiControls(); + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); // NEW + + overlayManager.updateAxisExtents(); + overlayManager.updateTimelineGeometry(surfPlot.getMaxAbsY() * 2); + } + + @Override + public void addSemanticMap(SemanticMap semanticMap) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public SemanticMap getSemanticMap(long id) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void locateSemanticMap(SemanticMap semanticMap) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void clearSemanticMaps() { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void addFeatureCollection(FeatureCollection featureCollection, boolean clearQueue) { + dataGrid.clear(); + List xList; + for (FeatureVector fv : featureCollection.getFeatures()) { + xList = new ArrayList<>(fv.getData().size()); + xList.addAll(fv.getData()); + dataGrid.add(xList); + } + zWidth = dataGrid.size(); + xWidth = dataGrid.get(0).size(); + syncGuiControls(); + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); + getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); + featureVectors.clear(); + featureVectors.addAll(featureCollection.getFeatures()); + interactionController.resetSelectionState(); + } + + @Override + public void addFeatureVector(FeatureVector featureVector) { + featureVectors.add(featureVector); + dataGrid.add(featureVector.getData()); + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); + } + + @Override + public void locateFeatureVector(FeatureVector featureVector) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void clearFeatureVectors() { + featureVectors.clear(); + dataGrid.clear(); + sourceModel.clearOriginalGrid(); + interactionController.resetSelectionState(); + resetLodDataState(); + } + + @Override + public List getAllFeatureVectors() { + if (null == featureVectors) return Collections.EMPTY_LIST; + return featureVectors; + } + + @Override + public void setColorByID(String iGotID, Color color) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setColorByIndex(int i, Color color) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void setVisibleByIndex(int i, boolean b) { + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void refresh() { + refresh(true); + } + + @Override + public void refresh(boolean forceNodeUpdate) { + updateTheMesh(); + } + + @Override + public void setDimensionLabels(List labelStrings) { + featureLabels.clear(); + if (labelStrings != null) featureLabels.addAll(labelStrings); + } + + @Override + public void setSpheroidAnchor(boolean animate, int index) { + // Hypersurface does not use spheroid anchoring. + } + + private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { + final int imageWidth = (int) image.getWidth(); + final int imageHeight = (int) image.getHeight(); + final int startX = Math.max(0, Math.min(x1, imageWidth)); + final int startY = Math.max(0, Math.min(y1, imageHeight)); + final int endX = Math.max(startX, Math.min(x2, imageWidth)); + final int endY = Math.max(startY, Math.min(y2, imageHeight)); + final int sourceWidth = endX - startX; + final int sourceHeight = endY - startY; + + if (sourceWidth <= 0 || sourceHeight <= 0) { + LOG.warn("Ignoring empty image tessellation region: ({}, {}) to ({}, {}) for {}x{} image", + x1, y1, x2, y2, imageWidth, imageHeight); + return; + } + + long startTime = System.nanoTime(); + System.out.println("Mapping Image Raster directly to capped primitive L0: " + + sourceWidth + "x" + sourceHeight); + + PixelReader pixelReader = image.getPixelReader(); + if (pixelReader == null) { + LOG.warn("Unable to tessellate image because PixelReader is unavailable."); + return; + } + + // Validate the incoming image first, then release every large render-side object + // derived from the previous image before allocating the new full-resolution + // primitive height array. This minimizes the transient heap peak when replacing + // 4K/8K sources. The method parameter retains the new Image while sourceModel.getSourceImage() and + // the tiled renderer are allowed to release the old one. + releaseImageBackedStateForReplacement(); + + // Retain source-region metadata, but do not allocate a full-resolution + // HeightField for large images. Build the capped raw L0 directly from PixelReader. + HeightField rawL0 = processingController.buildImageRawL0( + pixelReader, startX, startY, sourceWidth, sourceHeight); + sourceModel.activateImageSource( + image, startX, startY, sourceWidth, sourceHeight, rawL0); + interactionController.resetSelectionState(); + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_FAR); + + Utils.printTotalTime(startTime); + System.out.println("Injecting primitive image HeightField into Hypersurface..."); + startTime = System.nanoTime(); + + xWidth = sourceWidth; + zWidth = sourceHeight; + syncGuiControls(); + rebuildProcessedGridAndRefresh(); + overlayManager.updateAxisExtents(); + interactionController.publishSourceImageChanged(image); + Utils.printTotalTime(startTime); + } + + @Override + public void addShapleyCollection(ShapleyCollection shapleyCollection) { + shapleyVectors.clear(); + shapleyVectors.addAll(shapleyCollection.getValues()); + try { + WritableImage wi = ResourceUtils.loadImageFile(imageryBasePath + shapleyCollection.getSourceInput()); + if (null != wi) { + int x2 = (int) wi.getWidth(); + int y2 = (int) wi.getHeight(); + tessellateImage(wi, 0, 0, x2, y2); + LOG.info("injecting Shapley function values into Vertices... "); + long startTime = System.nanoTime(); + renderController.updateShapleyFunctionValues(shapleyVectors, yScale); + Utils.printTotalTime(startTime); + if (colorationMethod == COLORATION.COLOR_BY_SHAPLEY) { + // Rebuild through the legacy metadata path so p.f remains available. + updateTheMesh(); + } else { + applyCurrentColoration(); + } + } + } catch (IOException ex) { + LOG.error(null, ex); + } + } + + @Override + public void addShapleyVector(ShapleyVector shapleyVector) { + shapleyVectors.add(shapleyVector); + } + + @Override + public void clearShapleyVectors() { + shapleyVectors.clear(); + } + + // ================= source-model orchestration ================= + + /** + * Releases renderer-side objects derived from the previous image, then drops + * the corresponding image/HeightField state from the source model. + */ + private void releaseImageBackedStateForReplacement() { + renderController.releaseImageBackedRenderStateForReplacement(); + sourceModel.releaseImageBackedStateForReplacement(); + } + + /** Snapshot the current boxed working grid as the authoritative data source. */ + private void captureDataGridAsSource() { + boolean wasImageBacked = sourceModel.captureDataGridAsSource(); + if (wasImageBacked) { + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); + } + if (dataGrid.isEmpty()) { + resetLodDataState(); + } + } + + private void resetLodDataState() { + sourceModel.resetLodDataState(); + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); + renderController.resetLodRenderState(); + refreshLegendOverlay(); + } + + public boolean isImageBackedSurface() { + return sourceModel.isImageBackedSurface(); + } + + public int getRenderL0Width() { + return sourceModel.getRenderL0Width(xWidth); + } + + public int getRenderL0Height() { + return sourceModel.getRenderL0Height(zWidth); + } + + public int getSourceWidth() { + return sourceModel.getSourceWidth(xWidth); + } + + public int getSourceHeight() { + return sourceModel.getSourceHeight(zWidth); + } + + public int getRenderWidth() { + return sourceModel.getRenderWidth(xWidth); + } + + public int getRenderHeight() { + return sourceModel.getRenderHeight(zWidth); + } + + private HypersurfaceCameraController.SurfaceBounds getCameraSurfaceBounds() { + double maxAbsY = 0.0; + HeightField activeField = sourceModel.getActiveHeightField(); + HeightField processingField = sourceModel.getProcessingSourceHeightField(); + if (activeField != null) { + float[] minMax = activeField.minMax(); + maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + } else if (processingField != null) { + float[] minMax = processingField.minMax(); + maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + } else { + maxAbsY = renderController.getSurfaceMaxAbsY(); + } + + double worldHeight = Math.max(1.0, maxAbsY * 2.0); + return new HypersurfaceCameraController.SurfaceBounds( + getWorldWidth(), worldHeight, getWorldDepth()); + } + + public double getWorldWidth() { + return sourceModel.getWorldWidth(xWidth, zWidth, surfScale); + } + + public double getWorldDepth() { + return sourceModel.getWorldDepth(xWidth, zWidth, surfScale); + } + + private void refreshCoordinateMapper() { + coordinateMapper.configure( + getSourceWidth(), + getSourceHeight(), + getRenderWidth(), + getRenderHeight(), + getWorldWidth(), + getWorldDepth(), + getSurfaceRowOrientation()); + refreshCrosshairSurfaceContext(); + } + + private void refreshCrosshairSurfaceContext() { + surfaceCrosshairOverlay.configureSurface( + sourceModel.getActiveHeightField(), + coordinateMapper, + yScale, + getSurfaceRowOrientation(), + getSurfaceHeightOrientation()); + } + + double getActiveRenderValue(int row, int column) { + return sourceModel.getActiveRenderValue(row, column); + } + + List getActiveRenderRow(int row) { + return sourceModel.getActiveRenderRow(row); + } + + Double[] getActiveRenderColumn(int column) { + return sourceModel.getActiveRenderColumn(column); + } + + /** Recompute world extents after surfScale changes without rebuilding source data. */ + private void refreshWorldExtentsAndLodMetadata() { + renderController.refreshWorldExtentsAndLodMetadata(); + } + + /** + * Returns the long-axis world extent used for image-backed surfaces at the default + * surface scale. Image aspect ratio determines the other axis. + */ + public double getNominalImageWorldExtent() { + return sourceModel.getNominalImageWorldExtent(); + } + + /** + * Sets the long-axis world extent used for image-backed surfaces at the default + * surface scale. This changes physical display size, not source or LOD resolution. + */ + public void setNominalImageWorldExtent(double nominalImageWorldExtent) { + if (Double.compare(sourceModel.getNominalImageWorldExtent(), nominalImageWorldExtent) == 0) return; + sourceModel.setNominalImageWorldExtent(nominalImageWorldExtent); + if (sourceModel.isImageBackedSurface() + && sourceModel.getProcessingSourceHeightField() != null) { + refreshWorldExtentsAndLodMetadata(); + updateTheMesh(); + } + } + + public SurfaceRowOrientation getSurfaceRowOrientation() { + return sourceModel.getRowOrientation(); + } + + public void setSurfaceRowOrientation(SurfaceRowOrientation orientation) { + sourceModel.setRowOrientation(orientation); + } + + public ObjectProperty surfaceRowOrientationProperty() { + return sourceModel.rowOrientationProperty(); + } + + private void setSourceDefaultRowOrientation(SurfaceRowOrientation orientation) { + suppressRowOrientationRefresh = true; + try { + setSurfaceRowOrientation(orientation); + } finally { + suppressRowOrientationRefresh = false; + } + } + + public SurfaceHeightOrientation getSurfaceHeightOrientation() { + return sourceModel.getHeightOrientation(); + } + + public void setSurfaceHeightOrientation(SurfaceHeightOrientation orientation) { + sourceModel.setHeightOrientation(orientation); + } + + public ObjectProperty surfaceHeightOrientationProperty() { + return sourceModel.heightOrientationProperty(); + } + + public boolean isTiledHeightFieldRenderingEnabled() { + return renderController.isTiledHeightFieldRenderingEnabled(); + } + + public void setTiledHeightFieldRenderingEnabled(boolean enabled) { + renderController.setTiledHeightFieldRenderingEnabled(enabled); + } + + public int getTileCellsL0() { + return renderController.getTileCellsL0(); + } + + public TiledLodManager.Config getTiledLodConfigCopy() { + return renderController.getTiledLodConfigCopy(); + } + + public void setTiledLodConfig(TiledLodManager.Config config) { + renderController.setTiledLodConfig(config); + } + + public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { + return renderController.getTiledLodStatistics(); + } + + public void setTileCellsL0(int tileCellsL0) { + renderController.setTileCellsL0(tileCellsL0); + } + + public int getMaxRenderResolution() { + return processingController.getMaxRenderResolution(); + } + + public void setMaxRenderResolution(int maxRenderResolution) { + if (!processingController.setMaxRenderResolution(maxRenderResolution)) return; + processingController.rebuildCurrentImageRawL0(); + rebuildProcessedGridAndRefresh(); + } + + public int getMinRenderResolution() { + return processingController.getMinRenderResolution(); + } + + public void setMinRenderResolution(int minRenderResolution) { + if (!processingController.setMinRenderResolution(minRenderResolution)) return; + rebuildProcessedGridAndRefresh(); + } + + public boolean isTiledHeightFieldRendererActive() { + return renderController.isTiledHeightFieldRendererActive(); + } + + private void requestLodUpdate(LodManager.UpdateReason reason) { + renderController.requestLodUpdate(reason); + } + + private void forceLodUpdate() { + renderController.forceLodUpdate(); + } + + private void rebuildProcessedGridAndRefresh() { + if (!processingController.rebuildProcessedPyramid(xWidth, zWidth, surfScale)) return; + + TiledSurfaceRenderer tiledRenderer = renderController.getTiledSurfaceRenderer(); + if (sourceModel.isImageBackedSurface() + && (tiledRenderer == null || tiledRenderer.isVerboseDiagnosticsEnabled())) { + System.out.println(processingController.buildPyramidSummary()); + } + + refreshCoordinateMapper(); + renderController.activateProcessedPyramid(); + refreshLegendOverlay(); + } + +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java new file mode 100644 index 00000000..986dff0f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java @@ -0,0 +1,481 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.utils.JavaFX3DUtils; +import javafx.animation.Timeline; +import javafx.geometry.Point3D; +import javafx.scene.PerspectiveCamera; +import javafx.scene.SubScene; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.input.ZoomEvent; +import org.fxyz3d.utils.CameraTransformer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.function.Consumer; + +/** + * Owns camera input, camera motion, framing, and canonical view presets for the + * Hypersurface projection. + * + *

The controller deliberately operates on world-space surface bounds rather than + * source or render resolution. This keeps camera framing stable across LOD changes, + * tiled rendering, and large image sources. Rendering/LOD policy remains outside the + * controller and is notified through narrow interaction callbacks.

+ */ +public final class HypersurfaceCameraController { + + private static final Logger LOG = LoggerFactory.getLogger(HypersurfaceCameraController.class); + + public enum InteractionType { + DRAG, + ZOOM, + KEYBOARD + } + + public enum Preset { + OBLIQUE(-10.0, -45.0, 0.0), + TOP(-90.0, 0.0, 0.0), + FRONT(0.0, 0.0, 0.0), + SIDE(0.0, -90.0, 0.0); + + private final double rotateX; + private final double rotateY; + private final double rotateZ; + + Preset(double rotateX, double rotateY, double rotateZ) { + this.rotateX = rotateX; + this.rotateY = rotateY; + this.rotateZ = rotateZ; + } + + double rotateX() { + return rotateX; + } + + double rotateY() { + return rotateY; + } + + double rotateZ() { + return rotateZ; + } + } + + /** + * World-space extents centered around the Hypersurface origin. + */ + public record SurfaceBounds(double width, double height, double depth) { + public SurfaceBounds { + width = sanitizeExtent(width); + height = sanitizeExtent(height); + depth = sanitizeExtent(depth); + } + + private static double sanitizeExtent(double value) { + return Double.isFinite(value) && value > 0.0 ? value : 1.0; + } + } + + @FunctionalInterface + public interface BoundsProvider { + SurfaceBounds getSurfaceBounds(); + } + + private static final double DEFAULT_FIT_PADDING = 1.08; + private static final double MIN_HALF_FOV_RADIANS = Math.toRadians(1.0); + private static final double MIN_CAMERA_DISTANCE = 10.0; + private static final double GESTURE_ZOOM_STEP = 50.0; + private static final double MOUSE_ROTATION_FACTOR = 0.1; + private static final double MOUSE_PAN_FACTOR = 0.3; + + private final PerspectiveCamera camera; + private final CameraTransformer cameraTransform; + private final SubScene subScene; + private final BoundsProvider boundsProvider; + private final Consumer cameraChangedCallback; + private final Runnable cameraSettledCallback; + + private Timeline activeTransition; + private double fitPadding = DEFAULT_FIT_PADDING; + private double mousePosX; + private double mousePosY; + private double mouseOldX; + private double mouseOldY; + + public HypersurfaceCameraController(PerspectiveCamera camera, + CameraTransformer cameraTransform, + SubScene subScene, + BoundsProvider boundsProvider, + Consumer cameraChangedCallback, + Runnable cameraSettledCallback) { + if (camera == null) throw new IllegalArgumentException("camera cannot be null"); + if (cameraTransform == null) throw new IllegalArgumentException("cameraTransform cannot be null"); + if (subScene == null) throw new IllegalArgumentException("subScene cannot be null"); + if (boundsProvider == null) throw new IllegalArgumentException("boundsProvider cannot be null"); + + this.camera = camera; + this.cameraTransform = cameraTransform; + this.subScene = subScene; + this.boundsProvider = boundsProvider; + this.cameraChangedCallback = cameraChangedCallback != null + ? cameraChangedCallback + : ignored -> { }; + this.cameraSettledCallback = cameraSettledCallback != null + ? cameraSettledCallback + : () -> { }; + } + + /** + * Installs the camera-specific mouse and gesture handlers on the Hypersurface + * SubScene. Keyboard handling remains callable from the pane because the same + * key handler also owns non-camera Hypersurface shortcuts. + */ + public void installInputHandlers() { + subScene.setOnMousePressed(this::handleMousePressed); + subScene.setOnZoom(this::handleZoom); + subScene.setOnScroll(this::handleScroll); + subScene.setOnMouseDragged(this::handleMouseDragged); + subScene.setOnMouseReleased(this::handleMouseReleased); + } + + /** + * Handles camera-related keyboard shortcuts while allowing the owning pane to + * continue handling data/analysis shortcuts in the same KeyEvent callback. + * + * @return true when at least one camera action was applied + */ + public boolean handleKeyPressed(KeyEvent event) { + if (event == null) return false; + + KeyCode keycode = event.getCode(); + boolean cameraChanged = false; + + if ((keycode == KeyCode.NUMPAD0 && event.isControlDown()) + || (keycode == KeyCode.DIGIT0 && event.isControlDown())) { + reset(1000.0); + cameraChanged = true; + } else if ((keycode == KeyCode.NUMPAD0 && event.isShiftDown()) + || (keycode == KeyCode.DIGIT0 && event.isShiftDown())) { + reset(0.0); + cameraChanged = true; + } + + double change = event.isShiftDown() ? 100.0 : 10.0; + if (keycode == KeyCode.W) { + camera.setTranslateZ(camera.getTranslateZ() + change); + cameraChanged = true; + } + if (keycode == KeyCode.S) { + camera.setTranslateZ(camera.getTranslateZ() - change); + cameraChanged = true; + } + if (keycode == KeyCode.PLUS && event.isShortcutDown()) { + camera.setTranslateZ(camera.getTranslateZ() + change); + cameraChanged = true; + } + if (keycode == KeyCode.MINUS && event.isShortcutDown()) { + camera.setTranslateZ(camera.getTranslateZ() - change); + cameraChanged = true; + } + if (keycode == KeyCode.A) { + camera.setTranslateX(camera.getTranslateX() - change); + cameraChanged = true; + } + if (keycode == KeyCode.D) { + camera.setTranslateX(camera.getTranslateX() + change); + cameraChanged = true; + } + if (keycode == KeyCode.SPACE) { + camera.setTranslateY(camera.getTranslateY() + change); + cameraChanged = true; + } + if (keycode == KeyCode.X) { + camera.setTranslateY(camera.getTranslateY() - change); + cameraChanged = true; + } + + change = event.isShiftDown() ? 10.0 : 1.0; + if (keycode == KeyCode.NUMPAD7 || keycode == KeyCode.DIGIT8) { + cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() + change); + cameraChanged = true; + } + if (keycode == KeyCode.NUMPAD9 || (keycode == KeyCode.DIGIT8 && event.isControlDown())) { + cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() - change); + cameraChanged = true; + } + if (keycode == KeyCode.NUMPAD4 || keycode == KeyCode.DIGIT9) { + cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() + change); + cameraChanged = true; + } + if (keycode == KeyCode.NUMPAD6 || (keycode == KeyCode.DIGIT9 && event.isControlDown())) { + cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - change); + cameraChanged = true; + } + if (keycode == KeyCode.NUMPAD1 || keycode == KeyCode.DIGIT0) { + cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + change); + cameraChanged = true; + } + if (keycode == KeyCode.NUMPAD3 || (keycode == KeyCode.DIGIT0 && event.isControlDown())) { + cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() - change); + cameraChanged = true; + } + + if (cameraChanged) { + cameraChangedCallback.accept(InteractionType.KEYBOARD); + } + return cameraChanged; + } + + /** Moves the camera immediately to the supplied local-Z distance. */ + public void setCameraDistance(double distance) { + camera.setTranslateZ(distance); + } + + /** Historical Hypersurface intro: begin far away, then fit the current surface. */ + public void intro(double milliseconds, double introDistance) { + setCameraDistance(introDistance); + fit(milliseconds); + } + + /** Historical Hypersurface outtro: animate directly to the supplied distance. */ + public void outtro(double milliseconds, double outroDistance) { + JavaFX3DUtils.zoomTransition(milliseconds, camera, outroDistance); + } + + /** + * Fits the complete surface while preserving the current camera rotation. + */ + public void fit(double milliseconds) { + transitionTo( + cameraTransform.rx.getAngle(), + cameraTransform.ry.getAngle(), + cameraTransform.rz.getAngle(), + computeFitDistance( + cameraTransform.rx.getAngle(), + cameraTransform.ry.getAngle(), + cameraTransform.rz.getAngle()), + milliseconds); + } + + /** + * Applies a canonical orientation and fits the complete surface in that view. + */ + public void applyPreset(Preset preset, double milliseconds) { + if (preset == null) return; + transitionTo( + preset.rotateX(), + preset.rotateY(), + preset.rotateZ(), + computeFitDistance(preset.rotateX(), preset.rotateY(), preset.rotateZ()), + milliseconds); + } + + /** + * Historical reset semantics are the canonical oblique orientation, now fitted + * to the actual surface instead of using a fixed camera Z distance. + */ + public void reset(double milliseconds) { + applyPreset(Preset.OBLIQUE, milliseconds); + } + + public double getFitPadding() { + return fitPadding; + } + + public void setFitPadding(double fitPadding) { + if (!Double.isFinite(fitPadding) || fitPadding < 1.0) { + throw new IllegalArgumentException("fitPadding must be finite and >= 1.0"); + } + this.fitPadding = fitPadding; + } + + private void handleMousePressed(MouseEvent event) { + if (event.isSynthesized()) LOG.info("isSynthesized"); + mousePosX = event.getSceneX(); + mousePosY = event.getSceneY(); + mouseOldX = mousePosX; + mouseOldY = mousePosY; + } + + private void handleMouseDragged(MouseEvent event) { + mouseOldX = mousePosX; + mouseOldY = mousePosY; + mousePosX = event.getSceneX(); + mousePosY = event.getSceneY(); + + double mouseDeltaX = mousePosX - mouseOldX; + double mouseDeltaY = mousePosY - mouseOldY; + double modifier = 1.0; + if (event.isControlDown()) modifier = 0.1; + if (event.isShiftDown()) modifier = 25.0; + + if (event.isPrimaryButtonDown()) { + if (event.isAltDown()) { + cameraTransform.rz.setAngle(normalizeAngle( + cameraTransform.rz.getAngle() + + mouseDeltaX * MOUSE_ROTATION_FACTOR * modifier * 2.0)); + } else { + cameraTransform.ry.setAngle(normalizeAngle( + cameraTransform.ry.getAngle() + + mouseDeltaX * MOUSE_ROTATION_FACTOR * modifier * 2.0)); + cameraTransform.rx.setAngle(normalizeAngle( + cameraTransform.rx.getAngle() + - mouseDeltaY * MOUSE_ROTATION_FACTOR * modifier * 2.0)); + } + } else if (event.isMiddleButtonDown()) { + cameraTransform.t.setX(cameraTransform.t.getX() + + mouseDeltaX * MOUSE_ROTATION_FACTOR * modifier * MOUSE_PAN_FACTOR); + cameraTransform.t.setY(cameraTransform.t.getY() + + mouseDeltaY * MOUSE_ROTATION_FACTOR * modifier * MOUSE_PAN_FACTOR); + } + + // Preserve the prior behavior: every drag event requests an active-camera + // refresh, even when no recognized mouse button changed the transform. + cameraChangedCallback.accept(InteractionType.DRAG); + } + + private void handleZoom(ZoomEvent event) { + double deltaZ = event.getZoomFactor() > 1.0 + ? GESTURE_ZOOM_STEP + : -GESTURE_ZOOM_STEP; + zoom(deltaZ); + event.consume(); + } + + private void handleScroll(ScrollEvent event) { + double modifier = 50.0; + if (event.isControlDown()) modifier = 1.0; + if (event.isShiftDown()) modifier = 100.0; + zoom(event.getDeltaY() * MOUSE_ROTATION_FACTOR * modifier); + } + + private void handleMouseReleased(MouseEvent event) { + cameraSettledCallback.run(); + } + + private void zoom(double deltaZ) { + camera.setTranslateZ(camera.getTranslateZ() + deltaZ); + cameraChangedCallback.accept(InteractionType.ZOOM); + } + + private static double normalizeAngle(double angle) { + return ((angle % 360.0) + 540.0) % 360.0 - 180.0; + } + + /** + * Returns the negative local-Z camera translation required to contain the complete + * axis-aligned surface bounds at the requested camera orientation. The calculation + * evaluates all eight bounds corners in camera-local coordinates so Top/Front/Side + * views fit tightly instead of inheriting the excess margin of a bounding sphere. + */ + double computeFitDistance(double rotateX, double rotateY, double rotateZ) { + SurfaceBounds bounds = boundsProvider.getSurfaceBounds(); + if (bounds == null) bounds = new SurfaceBounds(1.0, 1.0, 1.0); + + double viewportWidth = Math.max(1.0, subScene.getWidth()); + double viewportHeight = Math.max(1.0, subScene.getHeight()); + double aspect = viewportWidth / viewportHeight; + + double primaryHalfFov = Math.toRadians(camera.getFieldOfView()) * 0.5; + primaryHalfFov = Math.max(MIN_HALF_FOV_RADIANS, primaryHalfFov); + + double verticalHalfFov; + double horizontalHalfFov; + if (camera.isVerticalFieldOfView()) { + verticalHalfFov = primaryHalfFov; + horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * aspect); + } else { + horizontalHalfFov = primaryHalfFov; + verticalHalfFov = Math.atan(Math.tan(horizontalHalfFov) / aspect); + } + + horizontalHalfFov = Math.max(MIN_HALF_FOV_RADIANS, horizontalHalfFov); + verticalHalfFov = Math.max(MIN_HALF_FOV_RADIANS, verticalHalfFov); + double tanHorizontal = Math.tan(horizontalHalfFov); + double tanVertical = Math.tan(verticalHalfFov); + + // Use the same CameraTransformer implementation as the live camera to map the + // world-space bounds into the requested camera orientation. parentToLocal() is + // the inverse of the transform that positions/orients the camera in the scene. + CameraTransformer orientation = new CameraTransformer(); + orientation.setRotate(rotateX, rotateY, rotateZ); + + double halfWidth = bounds.width() * 0.5; + double halfHeight = bounds.height() * 0.5; + double halfDepth = bounds.depth() * 0.5; + double requiredDistance = MIN_CAMERA_DISTANCE; + + double[] xs = {-halfWidth, halfWidth}; + double[] ys = {-halfHeight, halfHeight}; + double[] zs = {-halfDepth, halfDepth}; + for (double x : xs) { + for (double y : ys) { + for (double z : zs) { + Point3D local = orientation.parentToLocal(new Point3D(x, y, z)); + if (local == null) continue; + + // Camera local Z is negative while it looks toward +Z. For a camera + // distance d, a corner's forward distance is d + local.z. + double horizontalRequirement = Math.abs(local.getX()) / tanHorizontal - local.getZ(); + double verticalRequirement = Math.abs(local.getY()) / tanVertical - local.getZ(); + double nearClipRequirement = camera.getNearClip() - local.getZ(); + requiredDistance = Math.max(requiredDistance, horizontalRequirement); + requiredDistance = Math.max(requiredDistance, verticalRequirement); + requiredDistance = Math.max(requiredDistance, nearClipRequirement); + } + } + } + + requiredDistance = Math.max(MIN_CAMERA_DISTANCE, requiredDistance * fitPadding); + return -requiredDistance; + } + + private void transitionTo(double rotateX, + double rotateY, + double rotateZ, + double cameraZ, + double milliseconds) { + stopActiveTransition(); + + // Camera pan is interaction state, not part of a canonical fit. Re-center it + // before fitting while keeping the Hypersurface world origin as the orbit target. + cameraTransform.setPivot(0.0, 0.0, 0.0); + cameraTransform.setTranslate(0.0, 0.0, 0.0); + + double duration = Math.max(0.0, milliseconds); + if (duration == 0.0) { + cameraTransform.rx.setAngle(rotateX); + cameraTransform.ry.setAngle(rotateY); + cameraTransform.rz.setAngle(rotateZ); + camera.setTranslateX(0.0); + camera.setTranslateY(0.0); + camera.setTranslateZ(cameraZ); + cameraSettledCallback.run(); + return; + } + + activeTransition = JavaFX3DUtils.transitionCameraTo( + duration, + camera, + cameraTransform, + 0.0, + 0.0, + cameraZ, + rotateX, + rotateY, + rotateZ); + activeTransition.setOnFinished(event -> { + activeTransition = null; + cameraSettledCallback.run(); + }); + } + + private void stopActiveTransition() { + if (activeTransition == null) return; + activeTransition.stop(); + activeTransition = null; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceGraphController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceGraphController.java new file mode 100644 index 00000000..e6dcf94c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceGraphController.java @@ -0,0 +1,211 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; +import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; +import edu.jhuapl.trinity.javafx.renderers.Graph3DRenderer; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; +import javafx.event.Event; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +import java.util.function.Consumer; + +/** + * Owns the temporary 3D graph visual subsystem hosted by the Hypersurface view. + * + *

The graph was originally embedded in Hypersurface as an experiment. This + * controller deliberately avoids depending on {@link Hypersurface3DPane} so the + * graph rendering/style logic can later migrate to a dedicated 3D graph pane and + * SubScene with minimal restructuring. The optional graph consumer is the temporary + * bridge used to keep Hypersurface-specific graph-to-surface interaction working.

+ * + * @author Sean Phillips + */ +public final class HypersurfaceGraphController { + + private final Scene scene; + private final Consumer graphConsumer; + private final Group graphLayer = new Group(); + + private boolean graphVisible = true; + private GraphDirectedCollection currentGraph; + private final Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() + .withNodeRadius(20.0) + .withEdgeWidth(8.0f) + .withPositionScalar(1.0); + private GraphStyleParams styleParams = new GraphStyleParams(); + private boolean eventHandlersInstalled; + + public HypersurfaceGraphController(Scene scene, + Consumer graphConsumer) { + this.scene = scene; + this.graphConsumer = graphConsumer; + graphLayer.setVisible(graphVisible); + } + + + /** + * Synchronizes graph-owned state back to the existing graph controls. + */ + public void syncVisibilityGui() { + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + } + + public Group getGraphLayer() { + return graphLayer; + } + + public boolean isGraphVisible() { + return graphVisible; + } + + public GraphDirectedCollection getCurrentGraph() { + return currentGraph; + } + + public GraphStyleParams getStyleParams() { + return styleParams; + } + + /** + * Installs GraphEvent handlers once. Kept separate from construction so the host + * pane controls when the graph subsystem becomes event-active. + */ + public void installEventHandlers() { + if (scene == null || eventHandlersInstalled) return; + eventHandlersInstalled = true; + + scene.addEventHandler(GraphEvent.GRAPH_STYLE_PARAMS_CHANGED, e -> { + if (!(e.object instanceof GraphStyleParams p)) return; + + styleParams.nodeColor = p.nodeColor; + styleParams.nodeRadius = p.nodeRadius; + styleParams.nodeOpacity = clamp01(p.nodeOpacity); + styleParams.edgeColor = p.edgeColor; + styleParams.edgeWidth = p.edgeWidth; + styleParams.edgeOpacity = clamp01(p.edgeOpacity); + + applyGraphStyle(styleParams, true); + }); + + scene.addEventHandler(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, e -> { + styleParams = new GraphStyleParams(); + + graphParams.withNodeRadius(styleParams.nodeRadius) + .withEdgeWidth((float) styleParams.edgeWidth); + + if (currentGraph != null) { + rebuildGraph(); + } + applyGraphStyle(styleParams, false); + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + }); + + scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { + if (!(e.object instanceof GraphDirectedCollection graph)) return; + setGraph(graph); + }); + + scene.addEventHandler(GraphEvent.GRAPH_VISIBILITY_CHANGED, e -> { + if (!(e.object instanceof Boolean visible)) return; + setGraphVisible(visible); + }); + } + + private void setGraph(GraphDirectedCollection graph) { + currentGraph = graph; + if (graphConsumer != null) { + graphConsumer.accept(graph); + } + + rebuildGraph(); + applyGraphStyle(styleParams, false); + + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + scene.getRoot().fireEvent(new CommandTerminalEvent( + "Rendered 3D graph: nodes=" + graph.getNodes().size() + + ", edges=" + graph.getEdges().size(), + new Font("Consolas", 18), Color.LIGHTGREEN)); + } + + private void setGraphVisible(boolean visible) { + graphVisible = visible; + graphLayer.setVisible(visible); + } + + private void rebuildGraph() { + graphLayer.getChildren().clear(); + if (currentGraph != null) { + graphLayer.getChildren().add( + Graph3DRenderer.buildGraphGroup(currentGraph, graphParams)); + } + } + + /** + * Applies the current graph style. Geometry is rebuilt only when edge width + * changes because edge width is encoded into the generated edge geometry. + */ + private void applyGraphStyle(GraphStyleParams style, boolean rebuildIfNeeded) { + if (style == null) return; + + double currentEdgeWidth = graphParams.edgeWidth; + boolean needRebuild = Math.abs(style.edgeWidth - currentEdgeWidth) > 1.0e-6; + + if (needRebuild && rebuildIfNeeded && currentGraph != null) { + graphParams.withNodeRadius(style.nodeRadius) + .withEdgeWidth((float) style.edgeWidth); + rebuildGraph(); + } + + for (Node node : graphLayer.getChildren()) { + applyGraphStyleRecursive(node, style); + } + } + + private void applyGraphStyleRecursive(Node node, GraphStyleParams style) { + if (node instanceof AnimatedSphere sphere) { + if (style.nodeColor != null) { + sphere.setColor(new Color( + style.nodeColor.getRed(), + style.nodeColor.getGreen(), + style.nodeColor.getBlue(), + sphere.getPhongMaterial().getDiffuseColor() != null + ? sphere.getPhongMaterial().getDiffuseColor().getOpacity() + : 1.0)); + } + sphere.setSphereRadius(style.nodeRadius); + sphere.setMaterialOpacity(style.nodeOpacity); + } else if (node instanceof Tracer tracer) { + if (style.edgeColor != null) { + tracer.setDiffuseColor(new Color( + style.edgeColor.getRed(), + style.edgeColor.getGreen(), + style.edgeColor.getBlue(), + 1.0)); + } + tracer.setOpacityAlpha(style.edgeOpacity); + } else if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + applyGraphStyleRecursive(child, style); + } + } + } + + private void fireOnRoot(Event event) { + if (scene != null && scene.getRoot() != null) { + scene.getRoot().fireEvent(event); + } + } + + private static double clamp01(double value) { + return Math.max(0.0, Math.min(1.0, value)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java new file mode 100644 index 00000000..13328bf6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -0,0 +1,583 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.data.graph.GraphNode; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; +import javafx.application.Platform; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.image.Image; +import javafx.scene.image.PixelReader; +import javafx.scene.input.MouseEvent; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import org.fxyz3d.geometry.Point3D; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Optional; + +/** + * Coordinates transient interaction with a Hypersurface without owning surface + * rendering or source-data processing. + * + *

The controller resolves picked surface positions into source/render indices, + * dispatches hover-derived analysis events, coordinates crosshair requests, publishes + * FeatureVector selections for Trinity's shared inspection tools, and interprets graph + * hover/click events that map back onto surface rows.

+ * + * @author Sean Phillips + */ +public final class HypersurfaceInteractionController { + + /** + * Resolved semantic meaning of a picked surface position. Render row/column refer + * to the currently active HeightField while source row/column remain stable across + * LOD changes. + */ + public record SurfaceInteractionContext( + Point3D surfacePoint, + int renderRow, + int renderColumn, + int sourceRow, + int sourceColumn, + double value + ) { + } + + private final Hypersurface3DPane pane; + private final Scene scene; + private final SurfaceCoordinateMapper coordinateMapper; + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay; + private final HypersurfaceOverlayManager overlayManager; + + private boolean hoverEnabled; + private boolean surfaceChartsEnabled; + private boolean crosshairsEnabled; + + private FeatureVector lastPublishedFeatureVector; + private SurfaceInteractionContext lastInteractionContext; + private SurfaceInspection lastSurfaceInspection; + + private int lastStatsRenderRow = -1; + private int lastStatsRenderColumn = -1; + private double lastRowMinimum = Double.NaN; + private double lastRowMaximum = Double.NaN; + private double lastColumnMinimum = Double.NaN; + private double lastColumnMaximum = Double.NaN; + + private GraphDirectedCollection currentGraph; + private final IdentityHashMap graphNodeRowIndex = new IdentityHashMap<>(); + private final HashMap graphEntityIdToIndex = new HashMap<>(); + private int[][] graphAdjacencyIndices = new int[0][]; + + public HypersurfaceInteractionController(Hypersurface3DPane pane, + Scene scene, + SurfaceCoordinateMapper coordinateMapper, + SurfaceCrosshairOverlay surfaceCrosshairOverlay, + HypersurfaceOverlayManager overlayManager) { + this.pane = pane; + this.scene = scene; + this.coordinateMapper = coordinateMapper; + this.surfaceCrosshairOverlay = surfaceCrosshairOverlay; + this.overlayManager = overlayManager; + wireGraphInteractionHandlers(); + } + + /** + * Installs the common hover-picking path on both renderers. The legacy mesh reports + * points directly in surface coordinates; tiled MeshViews are first converted back + * through the tiled renderer's local coordinate system. + */ + public void installSurfacePicking(HyperSurfacePlotMesh surfPlot, + TiledSurfaceRenderer tiledSurfaceRenderer) { + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setHoverPickingEnabled(hoverEnabled); + tiledSurfaceRenderer.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { + if (!hoverEnabled) return; + Node picked = e.getPickResult().getIntersectedNode(); + javafx.geometry.Point3D pickedPoint = e.getPickResult().getIntersectedPoint(); + if (picked == null || pickedPoint == null) return; + javafx.geometry.Point3D scenePoint = picked.localToScene(pickedPoint); + javafx.geometry.Point3D surfacePoint = tiledSurfaceRenderer.sceneToLocal(scenePoint); + handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(surfacePoint)); + e.consume(); + }); + } + + if (surfPlot != null) { + surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { + if (!hoverEnabled) return; + javafx.geometry.Point3D pickedPoint = e.getPickResult().getIntersectedPoint(); + if (pickedPoint == null) return; + handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(pickedPoint)); + e.consume(); + }); + } + } + + public boolean isHoverEnabled() { + return hoverEnabled; + } + + public void setHoverEnabled(boolean hoverEnabled) { + this.hoverEnabled = hoverEnabled; + TiledSurfaceRenderer tiledSurfaceRenderer = pane.getTiledSurfaceRendererForInteraction(); + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setHoverPickingEnabled(hoverEnabled); + } + } + + public boolean isSurfaceChartsEnabled() { + return surfaceChartsEnabled; + } + + public void setSurfaceChartsEnabled(boolean surfaceChartsEnabled) { + this.surfaceChartsEnabled = surfaceChartsEnabled; + if (surfaceChartsEnabled) pane.showSurfaceChartsPane(); + } + + public boolean isCrosshairsEnabled() { + return crosshairsEnabled; + } + + public void setCrosshairsEnabled(boolean crosshairsEnabled) { + this.crosshairsEnabled = crosshairsEnabled; + if (!crosshairsEnabled) surfaceCrosshairOverlay.hide(); + } + + /** + * Opens Trinity's existing Content Navigator. Image-backed surfaces can provide + * their source image directly. Feature-backed surfaces republish the last hovered + * FeatureVector after the navigator has been shown so its existing event listener + * receives the current selection immediately. + */ + public void showContentNavigator(Image sourceImage) { + if (sourceImage != null) { + scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_NAVIGATOR_PANE, sourceImage)); + } else { + scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_NAVIGATOR_PANE)); + } + + if (lastPublishedFeatureVector != null) { + publishFeatureVector(lastPublishedFeatureVector, true); + } + if (lastSurfaceInspection != null) { + publishSurfaceInspection(lastSurfaceInspection); + } + } + + /** Publishes an accepted Hypersurface source image without forcing Navigator open. */ + public void publishSourceImageChanged(Image sourceImage) { + fireHypersurfaceEvent(HypersurfaceEvent.sourceImageChanged(sourceImage)); + } + + public SurfaceInteractionContext getLastInteractionContext() { + return lastInteractionContext; + } + + public void resetSelectionState() { + lastPublishedFeatureVector = null; + lastInteractionContext = null; + lastSurfaceInspection = null; + lastStatsRenderRow = -1; + lastStatsRenderColumn = -1; + lastRowMinimum = Double.NaN; + lastRowMaximum = Double.NaN; + lastColumnMinimum = Double.NaN; + lastColumnMaximum = Double.NaN; + surfaceCrosshairOverlay.hide(); + fireHypersurfaceEvent(HypersurfaceEvent.surfaceInspection(null)); + } + + /** + * Rebuilds the graph-to-surface interaction cache when graph content changes. + * Rendering/styling of the graph remains owned by Hypersurface3DPane. + */ + public void setGraph(GraphDirectedCollection graph) { + currentGraph = graph; + graphNodeRowIndex.clear(); + graphEntityIdToIndex.clear(); + graphAdjacencyIndices = new int[0][]; + if (graph == null || graph.getNodes() == null) return; + + List nodes = graph.getNodes(); + int n = nodes.size(); + int sourceHeight = pane.getSourceHeight(); + + HashMap validIdentities = new HashMap<>(); + boolean uniqueIdentityMapping = sourceHeight > 0; + for (int i = 0; i < n; i++) { + GraphNode node = nodes.get(i); + if (node == null) { + uniqueIdentityMapping = false; + continue; + } + graphEntityIdToIndex.put(node.getEntityID(), i); + long identity = node.getIdentity(); + if (identity < 0 || identity >= sourceHeight + || validIdentities.put(identity, node) != null) { + uniqueIdentityMapping = false; + } + } + + if (uniqueIdentityMapping && validIdentities.size() == n) { + for (GraphNode node : nodes) { + graphNodeRowIndex.put(node, (int) node.getIdentity()); + } + } else if (n == sourceHeight) { + for (int i = 0; i < n; i++) { + GraphNode node = nodes.get(i); + if (node != null) graphNodeRowIndex.put(node, i); + } + } + + ArrayList> adjacency = new ArrayList<>(n); + for (int i = 0; i < n; i++) adjacency.add(new ArrayList<>()); + if (graph.getEdges() != null) { + for (GraphEdge edge : graph.getEdges()) { + if (edge == null) continue; + Integer startIndex = graphEntityIdToIndex.get(edge.getStartID()); + Integer endIndex = graphEntityIdToIndex.get(edge.getEndID()); + if (startIndex == null || endIndex == null || startIndex.equals(endIndex)) continue; + adjacency.get(startIndex).add(endIndex); + adjacency.get(endIndex).add(startIndex); + } + } + + graphAdjacencyIndices = new int[n][]; + for (int i = 0; i < n; i++) { + ArrayList neighbors = adjacency.get(i); + int[] indices = new int[neighbors.size()]; + for (int j = 0; j < neighbors.size(); j++) indices[j] = neighbors.get(j); + graphAdjacencyIndices[i] = indices; + } + } + + private void handleSurfaceHover(Point3D surfacePoint) { + // Keep the 3D marker responsive at raw mouse-event frequency. Rich inspection + // state below is coalesced by source/render cell changes. + overlayManager.updateHoverMarker(surfacePoint); + + int renderRow = coordinateMapper.surfaceZToRenderRow(surfacePoint.getZ()); + int renderColumn = coordinateMapper.surfaceXToRenderColumn(surfacePoint.getX()); + int sourceRow = coordinateMapper.surfaceZToSourceRow(surfacePoint.getZ()); + int sourceColumn = coordinateMapper.surfaceXToSourceColumn(surfacePoint.getX()); + HeightField activeHeightField = pane.getActiveHeightFieldForInteraction(); + double value = activeHeightField != null + ? pane.getActiveRenderValue(renderRow, renderColumn) + : Double.NaN; + + SurfaceInteractionContext previous = lastInteractionContext; + SurfaceInteractionContext context = new SurfaceInteractionContext( + surfacePoint, renderRow, renderColumn, sourceRow, sourceColumn, value); + lastInteractionContext = context; + + publishFeatureSelection(sourceRow); + + if (crosshairsEnabled && activeHeightField != null) { + surfaceCrosshairOverlay.requestRenderPosition(renderRow, renderColumn); + } + + boolean renderCellChanged = previous == null + || previous.renderRow() != renderRow + || previous.renderColumn() != renderColumn; + boolean sourcePixelChanged = previous == null + || previous.sourceRow() != sourceRow + || previous.sourceColumn() != sourceColumn; + + if (activeHeightField != null && (sourcePixelChanged || renderCellChanged + || tileOrLodChanged(renderRow, renderColumn))) { + publishSurfaceInspection(buildSurfaceInspection(context)); + } + + if (surfaceChartsEnabled && activeHeightField != null && renderCellChanged) { + publishSurfaceChartData(context); + } + } + + private void publishFeatureSelection(int sourceRow) { + List featureVectors = pane.getFeatureVectorsForInteraction(); + if (sourceRow < 0 || sourceRow >= featureVectors.size()) { + lastPublishedFeatureVector = null; + return; + } + + FeatureVector featureVector = featureVectors.get(sourceRow); + if (featureVector == null || featureVector == lastPublishedFeatureVector) return; + lastPublishedFeatureVector = featureVector; + publishFeatureVector(featureVector, false); + } + + private void publishFeatureVector(FeatureVector featureVector, boolean force) { + if (featureVector == null) return; + if (!force && featureVector != lastPublishedFeatureVector) { + lastPublishedFeatureVector = featureVector; + } + scene.getRoot().fireEvent(new FeatureVectorEvent( + FeatureVectorEvent.SELECT_FEATURE_VECTOR, + featureVector, + pane.getFeatureLabelsForInteraction())); + } + + private SurfaceInspection buildSurfaceInspection(SurfaceInteractionContext context) { + refreshStatistics(context.renderRow(), context.renderColumn()); + + boolean imageBacked = pane.isImageBackedSurface(); + int imageColumn = context.sourceColumn(); + int imageRow = context.sourceRow(); + int red = -1; + int green = -1; + int blue = -1; + double intensity = Double.NaN; + + Image sourceImage = pane.getSourceImageForInteraction(); + if (imageBacked && sourceImage != null) { + imageColumn += pane.getImageSourceStartXForInteraction(); + imageRow += pane.getImageSourceStartYForInteraction(); + PixelReader pixelReader = sourceImage.getPixelReader(); + if (pixelReader != null) { + int imageWidth = (int) sourceImage.getWidth(); + int imageHeight = (int) sourceImage.getHeight(); + int px = Math.max(0, Math.min(imageColumn, Math.max(0, imageWidth - 1))); + int py = Math.max(0, Math.min(imageRow, Math.max(0, imageHeight - 1))); + int argb = pixelReader.getArgb(px, py); + red = (argb >> 16) & 0xFF; + green = (argb >> 8) & 0xFF; + blue = argb & 0xFF; + intensity = ((red + green + blue) / 3.0) / 255.0; + } + } + + int tileId = -1; + int activeLod = -1; + int tileImageMinX = -1; + int tileImageMinY = -1; + int tileImageMaxXExclusive = -1; + int tileImageMaxYExclusive = -1; + + TiledSurfaceRenderer tiledRenderer = pane.getTiledSurfaceRendererForInteraction(); + if (imageBacked && pane.isTiledHeightFieldRendererActive() && tiledRenderer != null) { + Optional tile = + tiledRenderer.getTileInspectionAt(context.renderRow(), context.renderColumn()); + if (tile.isPresent()) { + TiledSurfaceRenderer.TileInspection t = tile.get(); + tileId = t.tileId(); + activeLod = t.activeLod(); + + int sx1 = coordinateMapper.renderColumnBoundaryToSourceBoundary(t.startColumn()); + int sx2 = coordinateMapper.renderColumnBoundaryToSourceBoundary(t.endColumnExclusive()); + int sy1 = coordinateMapper.renderRowBoundaryToSourceBoundary(t.startRow()); + int sy2 = coordinateMapper.renderRowBoundaryToSourceBoundary(t.endRowExclusive()); + int sourceOffsetX = pane.getImageSourceStartXForInteraction(); + int sourceOffsetY = pane.getImageSourceStartYForInteraction(); + tileImageMinX = Math.min(sx1, sx2) + sourceOffsetX; + tileImageMaxXExclusive = Math.max(sx1, sx2) + sourceOffsetX; + tileImageMinY = Math.min(sy1, sy2) + sourceOffsetY; + tileImageMaxYExclusive = Math.max(sy1, sy2) + sourceOffsetY; + } + } + + double worldX = context.surfacePoint().getX() - pane.getWorldWidth() / 2.0; + double worldY = context.surfacePoint().getY(); + double worldZ = context.surfacePoint().getZ() - pane.getWorldDepth() / 2.0; + + return new SurfaceInspection( + imageBacked, + pane.getSourceWidth(), + pane.getSourceHeight(), + coordinateMapper.renderWidth(), + coordinateMapper.renderHeight(), + context.renderColumn(), + context.renderRow(), + context.sourceColumn(), + context.sourceRow(), + imageColumn, + imageRow, + context.value(), + worldX, + worldY, + worldZ, + lastRowMinimum, + lastRowMaximum, + lastColumnMinimum, + lastColumnMaximum, + red, + green, + blue, + intensity, + tileId, + activeLod, + tileImageMinX, + tileImageMinY, + tileImageMaxXExclusive, + tileImageMaxYExclusive); + } + + private void refreshStatistics(int renderRow, int renderColumn) { + HeightField field = pane.getActiveHeightFieldForInteraction(); + if (field == null) return; + + if (renderRow != lastStatsRenderRow) { + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (int column = 0; column < field.width(); column++) { + double value = pane.getActiveRenderValue(renderRow, column); + min = Math.min(min, value); + max = Math.max(max, value); + } + lastRowMinimum = Double.isFinite(min) ? min : Double.NaN; + lastRowMaximum = Double.isFinite(max) ? max : Double.NaN; + lastStatsRenderRow = renderRow; + } + + if (renderColumn != lastStatsRenderColumn) { + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (int row = 0; row < field.height(); row++) { + double value = pane.getActiveRenderValue(row, renderColumn); + min = Math.min(min, value); + max = Math.max(max, value); + } + lastColumnMinimum = Double.isFinite(min) ? min : Double.NaN; + lastColumnMaximum = Double.isFinite(max) ? max : Double.NaN; + lastStatsRenderColumn = renderColumn; + } + } + + private boolean tileOrLodChanged(int renderRow, int renderColumn) { + if (!pane.isImageBackedSurface() || !pane.isTiledHeightFieldRendererActive()) return false; + TiledSurfaceRenderer renderer = pane.getTiledSurfaceRendererForInteraction(); + if (renderer == null) return false; + Optional tile = + renderer.getTileInspectionAt(renderRow, renderColumn); + if (tile.isEmpty()) return lastSurfaceInspection != null && lastSurfaceInspection.hasTile(); + if (lastSurfaceInspection == null) return true; + return tile.get().tileId() != lastSurfaceInspection.tileId() + || tile.get().activeLod() != lastSurfaceInspection.activeLod(); + } + + private void publishSurfaceInspection(SurfaceInspection inspection) { + lastSurfaceInspection = inspection; + fireHypersurfaceEvent(HypersurfaceEvent.surfaceInspection(inspection)); + } + + private void fireHypersurfaceEvent(HypersurfaceEvent event) { + if (Platform.isFxApplicationThread()) { + scene.getRoot().fireEvent(event); + } else { + Platform.runLater(() -> scene.getRoot().fireEvent(event)); + } + } + + private void publishSurfaceChartData(SurfaceInteractionContext context) { + List xList = pane.getActiveRenderRow(context.renderRow()); + Double[] xRay = xList.toArray(Double[]::new); + Double[] zRay = pane.getActiveRenderColumn(context.renderColumn()); + + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, zRay)); + } + + private void wireGraphInteractionHandlers() { + scene.addEventHandler(GraphEvent.GRAPH_NODE_HOVER, e -> { + if (!(e.object instanceof GraphNode graphNode)) return; + if (surfaceChartsEnabled) { + Double[] row = buildAdjacencyRowFromGraph(graphNode); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Adjacency Row (hover): " + graphNode, + row)); + } + highlightSurfaceRowIfPossible(graphNode); + }); + + scene.addEventHandler(GraphEvent.GRAPH_NODE_CLICK, e -> { + if (!(e.object instanceof GraphNode graphNode)) return; + Double[] row = buildAdjacencyRowFromGraph(graphNode); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Adjacency Row (click): " + graphNode, + row)); + highlightSurfaceRowIfPossible(graphNode); + }); + + scene.addEventHandler(GraphEvent.GRAPH_EDGE_HOVER, e -> { + if (!(e.object instanceof GraphEdge graphEdge)) return; + Optional start = currentGraph != null + ? currentGraph.findNodeById(graphEdge.getStartID()) + : Optional.empty(); + Optional end = currentGraph != null + ? currentGraph.findNodeById(graphEdge.getEndID()) + : Optional.empty(); + double weight = getEdgeWeightSafe(graphEdge); + scene.getRoot().fireEvent(new CommandTerminalEvent( + "Edge hover: " + start.map(Object::toString).orElse("?") + " → " + + end.map(Object::toString).orElse("?") + " | weight = " + weight, + new Font("Consolas", 16), Color.ALICEBLUE)); + }); + + scene.addEventHandler(GraphEvent.GRAPH_EDGE_CLICK, e -> { + if (!(e.object instanceof GraphEdge graphEdge)) return; + double weight = getEdgeWeightSafe(graphEdge); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Edge Weight (click): " + graphEdge.getStartID() + " → " + + graphEdge.getEndID(), + new Double[]{weight})); + }); + } + + private Double[] buildAdjacencyRowFromGraph(GraphNode node) { + if (currentGraph == null || node == null) return new Double[0]; + Integer nodeIndex = graphEntityIdToIndex.get(node.getEntityID()); + if (nodeIndex == null || nodeIndex < 0 || nodeIndex >= graphAdjacencyIndices.length) { + return new Double[0]; + } + + int n = currentGraph.getNodes().size(); + Double[] out = new Double[n]; + Arrays.fill(out, 0.0); + for (int neighbor : graphAdjacencyIndices[nodeIndex]) { + if (neighbor >= 0 && neighbor < n) out[neighbor] = 1.0; + } + return out; + } + + private Optional getGraphSourceRowIndex(GraphNode node) { + if (node == null || currentGraph == null) return Optional.empty(); + Integer row = graphNodeRowIndex.get(node); + if (row == null || row < 0 || row >= pane.getSourceHeight()) return Optional.empty(); + return Optional.of(row); + } + + private void highlightSurfaceRowIfPossible(GraphNode node) { + if (!crosshairsEnabled || pane.getActiveHeightFieldForInteraction() == null) { + surfaceCrosshairOverlay.hide(); + return; + } + getGraphSourceRowIndex(node).ifPresent(surfaceCrosshairOverlay::requestSourceRow); + } + + private static double getEdgeWeightSafe(GraphEdge edge) { + try { + return (double) GraphEdge.class.getMethod("getWeight").invoke(edge); + } catch (Throwable t) { + return 1.0; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java new file mode 100644 index 00000000..d334773b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java @@ -0,0 +1,146 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.Background; +import javafx.scene.layout.BackgroundFill; +import javafx.scene.layout.Border; +import javafx.scene.layout.BorderStroke; +import javafx.scene.layout.BorderStrokeStyle; +import javafx.scene.layout.BorderWidths; +import javafx.scene.layout.CornerRadii; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; + +/** + * Passive 2D HUD that explains the current Hypersurface height and color semantics. + * The overlay is intended to live in Trinity's desktopPane rather than inside the + * 3D SubScene so it remains camera-independent and visually stable. + */ +public final class HypersurfaceLegendOverlay extends VBox { + + private static final double LEGEND_WIDTH = 252.0; + private static final double PALETTE_WIDTH = 112.0; + private static final double PALETTE_HEIGHT = 12.0; + + private final Label titleLabel = new Label("Surface"); + private final Label colorModeLabel = new Label("Color: -"); + private final Label colorMinimumLabel = new Label("-"); + private final Label colorMaximumLabel = new Label("-"); + private final ImageView paletteImageView = new ImageView( + SurfaceColorPalette.createPaletteImage(256)); + private final HBox paletteRow = new HBox(5); + private final Label heightModeLabel = new Label("Height: -"); + private final Label heightRangeLabel = new Label("Range: -"); + private final Label scaleLabel = new Label("Scale: -"); + private final Label orientationLabel = new Label("High Values: -"); + + public HypersurfaceLegendOverlay() { + setSpacing(4.0); + setPadding(new Insets(8.0, 10.0, 8.0, 10.0)); + setPrefWidth(LEGEND_WIDTH); + setMaxWidth(LEGEND_WIDTH); + setMouseTransparent(true); + + Color backgroundColor = Color.color(0.02, 0.03, 0.05, 0.78); + Color borderColor = Color.color(0.55, 0.80, 1.0, 0.62); + CornerRadii radii = new CornerRadii(4.0); + setBackground(new Background(new BackgroundFill( + backgroundColor, radii, Insets.EMPTY))); + setBorder(new Border(new BorderStroke( + borderColor, + BorderStrokeStyle.SOLID, + radii, + new BorderWidths(1.0)))); + + Font normalFont = Font.font("Consolas", 12.0); + titleLabel.setFont(Font.font("Consolas", FontWeight.BOLD, 13.0)); + titleLabel.setTextFill(Color.ALICEBLUE); + colorModeLabel.setFont(normalFont); + colorModeLabel.setTextFill(Color.WHITE); + colorMinimumLabel.setFont(normalFont); + colorMinimumLabel.setTextFill(Color.LIGHTGRAY); + colorMaximumLabel.setFont(normalFont); + colorMaximumLabel.setTextFill(Color.LIGHTGRAY); + heightModeLabel.setFont(normalFont); + heightModeLabel.setTextFill(Color.WHITE); + heightRangeLabel.setFont(normalFont); + heightRangeLabel.setTextFill(Color.LIGHTGRAY); + scaleLabel.setFont(normalFont); + scaleLabel.setTextFill(Color.LIGHTGRAY); + orientationLabel.setFont(normalFont); + orientationLabel.setTextFill(Color.LIGHTGRAY); + + paletteImageView.setFitWidth(PALETTE_WIDTH); + paletteImageView.setFitHeight(PALETTE_HEIGHT); + paletteImageView.setPreserveRatio(false); + paletteImageView.setSmooth(false); + paletteImageView.setMouseTransparent(true); + + paletteRow.setAlignment(Pos.CENTER_LEFT); + paletteRow.getChildren().addAll( + colorMinimumLabel, + paletteImageView, + colorMaximumLabel); + + HBox heightDetails = new HBox(12.0, scaleLabel, orientationLabel); + heightDetails.setAlignment(Pos.CENTER_LEFT); + + getChildren().addAll( + titleLabel, + colorModeLabel, + paletteRow, + heightModeLabel, + heightRangeLabel, + heightDetails); + } + + public void update(HypersurfaceLegendState state) { + if (state == null) return; + + colorModeLabel.setText("Color: " + safe(state.colorLabel())); + heightModeLabel.setText("Height: " + safe(state.heightLabel())); + heightRangeLabel.setText("Range: " + + formatRange(state.heightMinimum(), state.heightMaximum())); + scaleLabel.setText("Scale: " + formatNumber(state.heightScale()) + "x"); + orientationLabel.setText("High Values: " + + (state.heightOrientation() == SurfaceHeightOrientation.HIGH_VALUES_DOWN + ? "Down" + : "Up")); + + boolean showPalette = state.numericColorRange() + && Double.isFinite(state.colorMinimum()) + && Double.isFinite(state.colorMaximum()); + paletteRow.setVisible(showPalette); + paletteRow.setManaged(showPalette); + if (showPalette) { + colorMinimumLabel.setText(formatNumber(state.colorMinimum())); + colorMaximumLabel.setText(formatNumber(state.colorMaximum())); + } + } + + private static String safe(String value) { + return value == null || value.isBlank() ? "-" : value; + } + + private static String formatRange(double minimum, double maximum) { + if (!Double.isFinite(minimum) || !Double.isFinite(maximum)) return "-"; + return formatNumber(minimum) + " to " + formatNumber(maximum); + } + + private static String formatNumber(double value) { + if (!Double.isFinite(value)) return "-"; + double absolute = Math.abs(value); + if ((absolute > 0.0 && absolute < 0.001) || absolute >= 10000.0) { + return String.format("%.3e", value); + } + if (absolute >= 100.0) return String.format("%.1f", value); + if (absolute >= 10.0) return String.format("%.2f", value); + return String.format("%.3f", value); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java new file mode 100644 index 00000000..de4c2e61 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java @@ -0,0 +1,18 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +/** + * Immutable presentation state for the Hypersurface value/color legend. + */ +public record HypersurfaceLegendState( + String heightLabel, + double heightMinimum, + double heightMaximum, + double heightScale, + SurfaceHeightOrientation heightOrientation, + Hypersurface3DPane.COLORATION coloration, + String colorLabel, + boolean numericColorRange, + double colorMinimum, + double colorMaximum +) { +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java new file mode 100644 index 00000000..448d0e66 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java @@ -0,0 +1,424 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.javafx.components.callouts.Callout; +import edu.jhuapl.trinity.javafx.components.callouts.CalloutBuilder; +import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; +import edu.jhuapl.trinity.javafx.events.TimelineEvent; +import edu.jhuapl.trinity.javafx.javafx3d.images.SolidColorTexture; +import edu.jhuapl.trinity.utils.JavaFX3DUtils; +import edu.jhuapl.trinity.utils.ResourceUtils; +import javafx.application.Platform; +import javafx.geometry.Point2D; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.SubScene; +import javafx.scene.control.Label; +import javafx.scene.control.TitledPane; +import javafx.scene.effect.Glow; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.Box; +import javafx.scene.shape.Cylinder; +import javafx.scene.shape.DrawMode; +import javafx.scene.shape.Shape3D; +import javafx.scene.shape.Sphere; +import javafx.scene.text.Font; +import javafx.scene.text.Text; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Translate; +import org.fxyz3d.geometry.Point3D; + +import java.util.HashMap; +import java.util.Map; + +/** + * Owns the auxiliary 3D/2D visual overlays used by {@link Hypersurface3DPane}: + * axis markers and labels, the hover marker, timeline row markers, and FeatureVector + * callouts. Surface geometry, source data, interaction interpretation, and camera + * behavior remain outside this class. + * + * @author Sean Phillips + */ +public final class HypersurfaceOverlayManager { + + private static final double TIMELINE_POLE_HEIGHT = 60.0; + private static final double TIMELINE_RADIUS = 3.0; + + private final Hypersurface3DPane owner; + private final Scene scene; + private final SubScene subScene; + private final SurfaceCoordinateMapper coordinateMapper; + + private final Group nodeGroup = new Group(); + private final Group labelGroup = new Group(); + private final Group extrasGroup = new Group(); + + private final Map shape3DToLabel = new HashMap<>(); + private final Map shape3DToCalloutMap = new HashMap<>(); + + private final Sphere xSphere = new Sphere(10); + private final Sphere ySphere = new Sphere(10); + private final Sphere zSphere = new Sphere(10); + private final Sphere highlightedPoint = new Sphere(1, 32); + + private final Label xLabel = new Label("Features (ordered)"); + private final Label yLabel = new Label("Magnitude"); + private final Label zLabel = new Label("Time (Samples)"); + + private Box glowLineBox; + private Cylinder eastPole; + private Cylinder westPole; + private Sphere eastKnob; + private Sphere westKnob; + private Label eastLabel; + private Label westLabel; + private int anchorIndex; + private Callout anchorCallout; + + public HypersurfaceOverlayManager(Hypersurface3DPane owner, + Scene scene, + SubScene subScene, + SurfaceCoordinateMapper coordinateMapper, + double initialPlaneSize) { + this.owner = owner; + this.scene = scene; + this.subScene = subScene; + this.coordinateMapper = coordinateMapper; + + configureAxisMarkers(initialPlaneSize); + configureHighlightedPoint(); + configureLabels(); + wireSelectionHandlers(); + createAnchorCalloutWhenSceneReady(); + } + + private void configureAxisMarkers(double initialPlaneSize) { + nodeGroup.getChildren().addAll(xSphere, ySphere, zSphere); + nodeGroup.getTransforms().addAll( + new Rotate(0, Rotate.X_AXIS), + new Rotate(0, Rotate.Y_AXIS), + new Rotate(0, Rotate.Z_AXIS)); + + xSphere.setTranslateX(initialPlaneSize / 2.0); + xSphere.setMaterial(new PhongMaterial(Color.RED)); + ySphere.setTranslateY(-initialPlaneSize / 2.0); + ySphere.setMaterial(new PhongMaterial(Color.GREEN)); + zSphere.setTranslateZ(initialPlaneSize / 2.0); + zSphere.setMaterial(new PhongMaterial(Color.BLUE)); + } + + private void configureHighlightedPoint() { + PhongMaterial highlightedPointMaterial = + new PhongMaterial(null, null, null, null, null); + highlightedPointMaterial.setSelfIlluminationMap( + SolidColorTexture.of(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.666))); + highlightedPoint.setMaterial(highlightedPointMaterial); + highlightedPoint.setDrawMode(DrawMode.FILL); + highlightedPoint.setMouseTransparent(true); + } + + private void configureLabels() { + Font font = new Font("Consolas", 20); + xLabel.setTextFill(Color.YELLOW); + xLabel.setFont(font); + xLabel.setMouseTransparent(true); + yLabel.setTextFill(Color.SKYBLUE); + yLabel.setFont(font); + yLabel.setMouseTransparent(true); + zLabel.setTextFill(Color.LIGHTGREEN); + zLabel.setFont(font); + zLabel.setMouseTransparent(true); + + labelGroup.getChildren().addAll(xLabel, yLabel, zLabel); + labelGroup.setManaged(false); + shape3DToLabel.put(xSphere, xLabel); + shape3DToLabel.put(ySphere, yLabel); + shape3DToLabel.put(zSphere, zLabel); + } + + private void wireSelectionHandlers() { + scene.addEventHandler(TimelineEvent.TIMELINE_SAMPLE_INDEX, e -> { + if (glowLineBox == null) return; + anchorIndex = (int) e.object; + int maxSourceRow = Math.max(0, owner.getSourceHeight() - 1); + anchorIndex = Math.max(0, Math.min(anchorIndex, maxSourceRow)); + glowLineBox.setTranslateZ(coordinateMapper.sourceRowToWorldZ(anchorIndex)); + updateTimelineLabels(); + refreshProjectedOverlays(); + }); + + scene.addEventHandler(FeatureVectorEvent.SELECT_FEATURE_VECTOR, e -> { + if (anchorCallout == null || !(e.object instanceof FeatureVector featureVector)) return; + updateCalloutByFeatureVector(anchorCallout, featureVector); + }); + } + + private void createAnchorCalloutWhenSceneReady() { + subScene.sceneProperty().addListener((obs, oldScene, newScene) -> { + if (newScene == null || anchorCallout != null) return; + Platform.runLater(() -> { + if (anchorCallout != null) return; + FeatureVector dummy = FeatureVector.EMPTY_FEATURE_VECTOR("", 3); + anchorCallout = createCallout(highlightedPoint, dummy, subScene); + anchorCallout.play().setOnFinished(fin -> anchorCallout.setVisible(false)); + }); + }); + } + + /** Creates the timeline row marker geometry after the initial surface is available. */ + public void initializeTimelineMarkers() { + if (glowLineBox != null) return; + + Glow glow = new Glow(0.8); + double poleHeight = TIMELINE_POLE_HEIGHT; + double radius = TIMELINE_RADIUS; + + glowLineBox = new Box(owner.getWorldWidth(), poleHeight, radius); + glowLineBox.setMaterial(new PhongMaterial(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.2))); + glowLineBox.setDrawMode(DrawMode.FILL); + glowLineBox.setEffect(glow); + glowLineBox.setTranslateZ(-owner.getWorldDepth() / 2.0); + + eastPole = new Cylinder(radius * 2, poleHeight * 1.2); + westPole = new Cylinder(radius * 2, poleHeight * 1.2); + eastKnob = new Sphere(radius * 3); + westKnob = new Sphere(radius * 3); + + eastPole.setMaterial(new PhongMaterial(Color.STEELBLUE)); + westPole.setMaterial(new PhongMaterial(Color.STEELBLUE)); + PhongMaterial knobMaterial = new PhongMaterial(Color.ALICEBLUE); + eastKnob.setMaterial(knobMaterial); + westKnob.setMaterial(knobMaterial); + + eastPole.setTranslateX(owner.getWorldWidth() / 2.0); + westPole.setTranslateX(-owner.getWorldWidth() / 2.0); + eastKnob.setTranslateX(owner.getWorldWidth() / 2.0); + westKnob.setTranslateX(-owner.getWorldWidth() / 2.0); + eastKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); + westKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); + + eastPole.translateZProperty().bind(glowLineBox.translateZProperty()); + westPole.translateZProperty().bind(glowLineBox.translateZProperty()); + eastKnob.translateZProperty().bind(glowLineBox.translateZProperty()); + westKnob.translateZProperty().bind(glowLineBox.translateZProperty()); + + eastLabel = createTimelineLabel(); + westLabel = createTimelineLabel(); + labelGroup.getChildren().addAll(eastLabel, westLabel); + shape3DToLabel.put(eastKnob, eastLabel); + shape3DToLabel.put(westKnob, westLabel); + updateTimelineLabels(); + + extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); + updateLabels(); + } + + private static Label createTimelineLabel() { + Label label = new Label("Data Index"); + label.setTextFill(Color.ALICEBLUE); + label.setFont(new Font("calibri", 20)); + return label; + } + + public Group getNodeGroup() { + return nodeGroup; + } + + public Group getLabelGroup() { + return labelGroup; + } + + public Group getExtrasGroup() { + return extrasGroup; + } + + public Sphere getHighlightedPoint() { + return highlightedPoint; + } + + public Callout getAnchorCallout() { + return anchorCallout; + } + + public void setDataMarkersVisible(boolean visible) { + extrasGroup.setVisible(visible); + labelGroup.setVisible(visible); + if (anchorCallout != null) anchorCallout.setVisible(visible); + } + + public void setExtrasVisible(boolean visible) { + extrasGroup.setVisible(visible); + } + + public void setAxesAndLabelsVisible(boolean visible) { + nodeGroup.setVisible(visible); + labelGroup.setVisible(visible); + } + + public void setInitialVisibility(boolean visible) { + extrasGroup.setVisible(visible); + labelGroup.setVisible(visible); + } + + public void moveTimeline(double deltaZ) { + if (glowLineBox != null) { + glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + deltaZ); + } + } + + public void updateAxisExtents() { + xSphere.setTranslateX(owner.getWorldWidth() / 2.0); + zSphere.setTranslateZ(owner.getWorldDepth() / 2.0); + } + + public void updateTimelineGeometry(double poleHeight) { + if (glowLineBox == null) return; + glowLineBox.setWidth(owner.getWorldWidth()); + glowLineBox.setHeight(poleHeight); + eastPole.setHeight(poleHeight * 1.2); + westPole.setHeight(poleHeight * 1.2); + eastPole.setTranslateX(-owner.getWorldWidth() / 2.0); + westPole.setTranslateX(owner.getWorldWidth() / 2.0); + eastKnob.setTranslateX(owner.getWorldWidth() / 2.0); + westKnob.setTranslateX(-owner.getWorldWidth() / 2.0); + eastKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); + westKnob.setTranslateY(-(poleHeight * 1.2) / 2.0); + updateTimelineLabels(); + updateLabels(); + } + + private void updateTimelineLabels() { + if (eastLabel == null || westLabel == null) return; + eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + owner.getSourceWidth()); + westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); + } + + public void updateHoverMarker(Point3D surfacePoint) { + highlightedPoint.setTranslateX(surfacePoint.x - owner.getWorldWidth() / 2.0); + highlightedPoint.setTranslateY(surfacePoint.y); + highlightedPoint.setTranslateZ(surfacePoint.z - owner.getWorldDepth() / 2.0); + refreshProjectedOverlays(); + } + + public void refreshProjectedOverlays() { + updateLabels(); + updateCalloutHeadPoints(subScene); + } + + public void updateLabels() { + shape3DToLabel.forEach((shape3D, node) -> { + Point2D point = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, 5); + node.getTransforms().setAll(new Translate(point.getX(), point.getY() - 25)); + }); + } + + public void resetLabelTracking() { + shape3DToLabel.clear(); + shape3DToLabel.put(xSphere, xLabel); + shape3DToLabel.put(ySphere, yLabel); + shape3DToLabel.put(zSphere, zLabel); + } + + public void updateCalloutHeadPoint(Shape3D node, Callout callout, SubScene targetSubScene) { + Point2D point = JavaFX3DUtils.getTransformedP2D( + node, targetSubScene, callout.head.getRadius() + 5); + callout.updateHeadPoint(point.getX(), point.getY()); + } + + public void updateCalloutHeadPoints(SubScene targetSubScene) { + shape3DToCalloutMap.forEach((node, callout) -> + updateCalloutHeadPoint(node, callout, targetSubScene)); + } + + public Callout createCallout(Shape3D shape3D, + FeatureVector featureVector, + SubScene targetSubScene) { + ImageView imageView = loadImageView(featureVector, featureVector.isBBoxValid()); + imageView.setPreserveRatio(true); + imageView.setFitWidth(Hypersurface3DPane.CHIP_FIT_WIDTH); + imageView.setFitHeight(Hypersurface3DPane.CHIP_FIT_WIDTH); + + TitledPane imagePane = new TitledPane(); + imagePane.setContent(imageView); + imagePane.setText("Imagery"); + + Point2D point = JavaFX3DUtils.getTransformedP2D( + shape3D, targetSubScene, Callout.DEFAULT_HEAD_RADIUS + 5); + + StringBuilder metadata = new StringBuilder(); + for (Map.Entry entry : featureVector.getMetaData().entrySet()) { + metadata.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); + } + TitledPane metadataPane = new TitledPane(); + metadataPane.setContent(new Text(metadata.toString())); + metadataPane.setText("Metadata"); + + Callout callout = CalloutBuilder.create() + .headPoint(point.getX(), point.getY()) + .leaderLineToPoint(point.getX() - 100, point.getY() - 150) + .endLeaderLineRight() + .mainTitle(featureVector.getLabel(), new VBox(3, imagePane, metadataPane)) + .subTitle(featureVector.getEntityId()) + .pause(10) + .build(); + callout.setPickOnBounds(false); + callout.setManaged(false); + addCallout(callout, shape3D); + callout.play().setOnFinished(eh -> { + if (featureVector.getImageURL() == null || featureVector.getImageURL().isBlank()) { + imagePane.setExpanded(false); + } + }); + return callout; + } + + public void addCallout(Callout callout, Shape3D shape3D) { + callout.setManaged(false); + owner.getChildren().add(callout); + shape3DToCalloutMap.put(shape3D, callout); + } + + public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { + callout.setMainTitleText(featureVector.getLabel()); + callout.mainTitleTextNode.setText(callout.getMainTitleText()); + VBox vbox = (VBox) callout.mainTitleNode; + TitledPane imagePane = (TitledPane) vbox.getChildren().get(0); + ImageView imageView = loadImageView(featureVector, featureVector.isBBoxValid()); + Image image = imageView.getImage(); + ((ImageView) imagePane.getContent()).setImage(image); + + StringBuilder metadata = new StringBuilder(); + for (Map.Entry entry : featureVector.getMetaData().entrySet()) { + metadata.append(entry.getKey()).append(" : ").append(entry.getValue()).append("\n"); + } + TitledPane metadataPane = (TitledPane) vbox.getChildren().get(1); + ((Text) metadataPane.getContent()).setText(metadata.toString()); + } + + private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { + try { + if (bboxOnly) { + WritableImage image = ResourceUtils.loadImageFileSubset( + owner.imageryBasePath + featureVector.getImageURL(), + featureVector.getBbox().get(0).intValue(), + featureVector.getBbox().get(1).intValue(), + featureVector.getBbox().get(2).intValue(), + featureVector.getBbox().get(3).intValue()); + return new ImageView(image); + } + if (featureVector.getImageURL() != null && !featureVector.getImageURL().isBlank()) { + return new ImageView(ResourceUtils.loadImageFile( + owner.imageryBasePath + featureVector.getImageURL())); + } + return new ImageView(ResourceUtils.loadIconFile("noimage")); + } catch (Exception ex) { + return new ImageView(ResourceUtils.loadIconFile("noimage")); + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceProcessingController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceProcessingController.java new file mode 100644 index 00000000..9ed27caf --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceProcessingController.java @@ -0,0 +1,417 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.javafx.javafx3d.Vert3D; +import edu.jhuapl.trinity.utils.DataUtils; +import edu.jhuapl.trinity.utils.DataUtils.HeightMode; +import javafx.scene.image.Image; +import javafx.scene.image.PixelReader; + +import java.util.List; + +/** + * Owns Hypersurface data-processing configuration and source-to-heightfield + * transformations. + * + *

This controller deliberately contains no scene-graph, renderer, control, + * or LOD scheduling behavior. It transforms source data owned by + * {@link HypersurfaceSourceModel}, builds the processed HeightField pyramid, + * and provides interpolation/sampling behavior used by the legacy mesh path.

+ * + * @author Sean Phillips + */ +public final class HypersurfaceProcessingController { + + private static final int DEFAULT_MAX_RENDER_RESOLUTION = 2048; + private static final int DEFAULT_MIN_RENDER_RESOLUTION = 512; + private static final int TILED_LOD_LEVEL_COUNT = 5; + + private final HypersurfaceSourceModel sourceModel; + + private HeightMode heightMode = HeightMode.RAW; + private boolean smoothingEnabled; + private SurfaceUtils.Smoothing smoothingMethod = SurfaceUtils.Smoothing.GAUSSIAN; + private int smoothingRadius = 2; + private double gaussianSigma = 1.0; + private SurfaceUtils.Interpolation interpolationMode = SurfaceUtils.Interpolation.NEAREST; + private boolean toneEnabled; + private SurfaceUtils.ToneMap toneOperator = SurfaceUtils.ToneMap.NONE; + private double toneParam = 2.0; + + private int maxRenderResolution = DEFAULT_MAX_RENDER_RESOLUTION; + private int minRenderResolution = DEFAULT_MIN_RENDER_RESOLUTION; + + public HypersurfaceProcessingController(HypersurfaceSourceModel sourceModel) { + if (sourceModel == null) { + throw new IllegalArgumentException("sourceModel cannot be null"); + } + this.sourceModel = sourceModel; + } + + public void setHeightMode(HeightMode heightMode) { + this.heightMode = heightMode != null ? heightMode : HeightMode.RAW; + } + + public void setSmoothingEnabled(boolean smoothingEnabled) { + this.smoothingEnabled = smoothingEnabled; + } + + public void setSmoothingMethod(SurfaceUtils.Smoothing smoothingMethod) { + this.smoothingMethod = smoothingMethod != null + ? smoothingMethod + : SurfaceUtils.Smoothing.GAUSSIAN; + } + + public void setSmoothingRadius(int smoothingRadius) { + this.smoothingRadius = smoothingRadius; + } + + public void setGaussianSigma(double gaussianSigma) { + this.gaussianSigma = gaussianSigma; + } + + public void setInterpolationMode(SurfaceUtils.Interpolation interpolationMode) { + this.interpolationMode = interpolationMode != null + ? interpolationMode + : SurfaceUtils.Interpolation.NEAREST; + } + + public void setToneEnabled(boolean toneEnabled) { + this.toneEnabled = toneEnabled; + } + + public void setToneOperator(SurfaceUtils.ToneMap toneOperator) { + this.toneOperator = toneOperator != null + ? toneOperator + : SurfaceUtils.ToneMap.NONE; + } + + public void setToneParam(double toneParam) { + this.toneParam = toneParam; + } + + public int getMaxRenderResolution() { + return maxRenderResolution; + } + + public boolean setMaxRenderResolution(int maxRenderResolution) { + if (maxRenderResolution <= 0) { + throw new IllegalArgumentException("maxRenderResolution must be > 0"); + } + if (maxRenderResolution < minRenderResolution) { + throw new IllegalArgumentException("maxRenderResolution must be >= minRenderResolution"); + } + if (this.maxRenderResolution == maxRenderResolution) return false; + this.maxRenderResolution = maxRenderResolution; + return true; + } + + public int getMinRenderResolution() { + return minRenderResolution; + } + + public boolean setMinRenderResolution(int minRenderResolution) { + if (minRenderResolution <= 0) { + throw new IllegalArgumentException("minRenderResolution must be > 0"); + } + if (minRenderResolution > maxRenderResolution) { + throw new IllegalArgumentException("minRenderResolution must be <= maxRenderResolution"); + } + if (this.minRenderResolution == minRenderResolution) return false; + this.minRenderResolution = minRenderResolution; + return true; + } + + public List> normalizeAndScale(List> grid, double userScale) { + return DataUtils.normalizeAndScale(grid, heightMode, userScale); + } + + /** + * Build the capped raw image L0 directly from the supplied PixelReader. + */ + public HeightField buildImageRawL0(PixelReader pixelReader, + int startX, + int startY, + int sourceWidth, + int sourceHeight) { + return buildImageRawL0(pixelReader, startX, startY, + sourceWidth, sourceHeight, maxRenderResolution); + } + + /** + * Rebuild the raw image L0 for the currently active image source after a + * render-resolution change. + * + * @return true when the current image source was rebuilt + */ + public boolean rebuildCurrentImageRawL0() { + if (!sourceModel.isImageBackedSurface() + || sourceModel.getSourceImage() == null + || sourceModel.getImageSourceWidth() <= 0 + || sourceModel.getImageSourceHeight() <= 0) { + return false; + } + Image image = sourceModel.getSourceImage(); + PixelReader reader = image.getPixelReader(); + if (reader == null) return false; + + sourceModel.setProcessingSourceHeightField(buildImageRawL0( + reader, + sourceModel.getImageSourceStartX(), + sourceModel.getImageSourceStartY(), + sourceModel.getImageSourceWidth(), + sourceModel.getImageSourceHeight())); + return true; + } + + /** + * Rebuild the processed L0 and lower-resolution pyramid from the current + * authoritative source state. + * + *

The source model is updated atomically from the perspective of callers: + * a new pyramid replaces the previous one, active LOD state is invalidated, + * and stable world extents are recomputed. Renderer selection and LOD + * scheduling remain the responsibility of Hypersurface3DPane.

+ * + * @return true when a processed pyramid was produced + */ + public boolean rebuildProcessedPyramid(int fallbackWidth, + int fallbackHeight, + double surfaceScale) { + if (sourceModel.getProcessingSourceHeightField() == null) { + if (sourceModel.getOriginalGrid() == null || sourceModel.getOriginalGrid().isEmpty()) { + return false; + } + sourceModel.setProcessingSourceHeightField( + SurfaceUtils.toHeightField(sourceModel.getOriginalGrid())); + } + + HeightField l0Raw = sourceModel.isImageBackedSurface() + ? sourceModel.getProcessingSourceHeightField() + : SurfaceUtils.buildL0Raw( + sourceModel.getProcessingSourceHeightField(), maxRenderResolution); + + SurfaceUtils.Smoothing smoothing = smoothingEnabled + ? smoothingMethod + : SurfaceUtils.Smoothing.NONE; + SurfaceUtils.ToneMap toneMap = toneEnabled + ? toneOperator + : SurfaceUtils.ToneMap.NONE; + + HeightField l0Processed = SurfaceUtils.processL0( + l0Raw, + smoothing, + smoothingRadius, + 1, + gaussianSigma, + toneMap, + toneParam); + + sourceModel.setLodProcessedLevels(sourceModel.isImageBackedSurface() + ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) + : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution)); + + sourceModel.recomputeBaseWorldExtents( + fallbackWidth, fallbackHeight, surfaceScale); + sourceModel.setActiveLod(-1, null); + sourceModel.clearCurrentLodSurfScale(); + return true; + } + + public String buildPyramidSummary() { + StringBuilder summary = new StringBuilder("Hypersurface tiled LOD pyramid: "); + List levels = sourceModel.getLodProcessedLevels(); + for (int i = 0; i < levels.size(); i++) { + if (i > 0) summary.append(", "); + HeightField level = levels.get(i); + summary.append('L').append(i).append('=') + .append(level.width()).append('x').append(level.height()); + } + return summary.toString(); + } + + /** + * Height lookup used by the legacy HyperSurfacePlotMesh callback. + */ + public Number sampleHeight(Vert3D point, + boolean surfaceRender, + double surfaceScale, + double yScale) { + if (!surfaceRender) { + if (sourceModel.getDataGrid().isEmpty()) return 0.0; + return findBlerpHeight(point, yScale); + } + + HeightField activeHeightField = sourceModel.getActiveHeightField(); + if (activeHeightField == null) { + if (sourceModel.getDataGrid().isEmpty()) return 0.0; + return switch (interpolationMode) { + case BILINEAR, BICUBIC -> { + double gx = point.xIndex + frac(point.getX() / Math.max(1.0, surfaceScale)); + double gy = point.yIndex + frac(point.getY() / Math.max(1.0, surfaceScale)); + gy = sourceModel.orientRowCoordinate(gy, sourceModel.getDataGrid().size()); + yield SurfaceUtils.sample( + sourceModel.getDataGrid(), gx, gy, interpolationMode); + } + case NEAREST -> lookupPoint(point); + }; + } + + return switch (interpolationMode) { + case BILINEAR, BICUBIC -> { + double scaleX = Double.isFinite(sourceModel.getCurrentLodSurfScaleX()) + ? sourceModel.getCurrentLodSurfScaleX() + : surfaceScale; + double scaleZ = Double.isFinite(sourceModel.getCurrentLodSurfScaleZ()) + ? sourceModel.getCurrentLodSurfScaleZ() + : surfaceScale; + double gx = point.xIndex + frac(point.getX() / Math.max(1.0, scaleX)); + double gy = point.yIndex + frac(point.getY() / Math.max(1.0, scaleZ)); + gy = sourceModel.orientRowCoordinate(gy, activeHeightField.height()); + yield SurfaceUtils.sample(activeHeightField, gx, gy, interpolationMode); + } + case NEAREST -> lookupPoint(point); + }; + } + + private Number lookupPoint(Vert3D point) { + HeightField activeHeightField = sourceModel.getActiveHeightField(); + if (activeHeightField != null) { + int width = activeHeightField.width(); + int height = activeHeightField.height(); + if (point.yIndex < 0 || point.yIndex >= height + || point.xIndex < 0 || point.xIndex >= width) { + return 0.0; + } + int dataRow = sourceModel.orientRowIndex(point.yIndex, height); + return (double) activeHeightField.data()[dataRow * width + point.xIndex]; + } + + List> dataGrid = sourceModel.getDataGrid(); + if (dataGrid.isEmpty()) return 0.0; + if (point.yIndex < 0 || point.yIndex >= dataGrid.size() + || point.xIndex < 0 || point.xIndex >= dataGrid.get(0).size()) { + return 0.0; + } + int dataRow = sourceModel.orientRowIndex(point.yIndex, dataGrid.size()); + return dataGrid.get(dataRow).get(point.xIndex); + } + + private Number findBlerpHeight(Vert3D point, double yScale) { + HeightField activeHeightField = sourceModel.getActiveHeightField(); + if (activeHeightField != null) { + int width = activeHeightField.width(); + int height = activeHeightField.height(); + if (width <= 0 || height <= 0) return 0.0; + + int x1Index = point.xIndex <= 0 ? 0 : point.xIndex - 1; + if (x1Index >= width - 1) x1Index = width - 1; + int x2Index = point.xIndex >= width - 1 ? width - 1 : point.xIndex + 1; + + int y1Index = point.yIndex <= 0 ? 0 : point.yIndex - 1; + if (y1Index >= height - 1) y1Index = height - 1; + int y2Index = point.yIndex >= height - 1 ? height - 1 : point.yIndex + 1; + + float[] data = activeHeightField.data(); + double c11 = data[y1Index * width + x1Index] * yScale; + double c21 = data[y1Index * width + x2Index] * yScale; + double c12 = data[y2Index * width + x1Index] * yScale; + double c22 = data[y2Index * width + x2Index] * yScale; + return quickBlerp(c11, c21, c12, c22, point.getX(), point.getY()); + } + + List> dataGrid = sourceModel.getDataGrid(); + if (dataGrid.isEmpty()) return 0.0; + + int width = dataGrid.get(0).size(); + int height = dataGrid.size(); + int x1Index = point.xIndex <= 0 ? 0 : point.xIndex - 1; + if (x1Index >= width - 1) x1Index = width - 1; + int x2Index = point.xIndex >= width - 1 ? width - 1 : point.xIndex + 1; + int y1Index = point.yIndex <= 0 ? 0 : point.yIndex - 1; + if (y1Index >= height - 1) y1Index = height - 1; + int y2Index = point.yIndex >= height - 1 ? height - 1 : point.yIndex + 1; + + double c11 = dataGrid.get(y1Index).get(x1Index) * yScale; + double c21 = dataGrid.get(y1Index).get(x2Index) * yScale; + double c12 = dataGrid.get(y2Index).get(x1Index) * yScale; + double c22 = dataGrid.get(y2Index).get(x2Index) * yScale; + return quickBlerp(c11, c21, c12, c22, point.getX(), point.getY()); + } + + private static double frac(double value) { + value = value - Math.floor(value); + return value < 0 ? value + 1.0 : value; + } + + private static double quickBlerp(double f1, + double f2, + double f3, + double f4, + double x, + double y) { + double xratio = x - Math.floor(x); + double yratio = y - Math.floor(y); + double f12 = f1 + (f2 - f1) * xratio; + double f34 = f3 + (f4 - f3) * xratio; + return f12 + (f34 - f12) * yratio; + } + + private static HeightField buildImageRawL0(PixelReader pixelReader, + int startX, + int startY, + int sourceWidth, + int sourceHeight, + int maxRender) { + int[] dimensions = SurfaceUtils.computeL0Dimensions( + sourceWidth, sourceHeight, maxRender); + int targetWidth = dimensions[0]; + int targetHeight = dimensions[1]; + float[] output = new float[Math.multiplyExact(targetWidth, targetHeight)]; + + if (targetWidth == sourceWidth && targetHeight == sourceHeight) { + int index = 0; + for (int y = 0; y < sourceHeight; y++) { + int sourceY = startY + y; + for (int x = 0; x < sourceWidth; x++) { + output[index++] = grayscale( + pixelReader.getArgb(startX + x, sourceY)); + } + } + return new HeightField(targetWidth, targetHeight, output); + } + + double xRatio = sourceWidth / (double) targetWidth; + double yRatio = sourceHeight / (double) targetHeight; + for (int y = 0; y < targetHeight; y++) { + double gy = (y + 0.5) * yRatio - 0.5; + gy = Math.max(0.0, Math.min(sourceHeight - 1.0, gy)); + int y0 = (int) Math.floor(gy); + int y1 = Math.min(sourceHeight - 1, y0 + 1); + double ty = y0 == y1 ? 0.0 : gy - y0; + + for (int x = 0; x < targetWidth; x++) { + double gx = (x + 0.5) * xRatio - 0.5; + gx = Math.max(0.0, Math.min(sourceWidth - 1.0, gx)); + int x0 = (int) Math.floor(gx); + int x1 = Math.min(sourceWidth - 1, x0 + 1); + double tx = x0 == x1 ? 0.0 : gx - x0; + + double c00 = grayscale(pixelReader.getArgb(startX + x0, startY + y0)); + double c10 = grayscale(pixelReader.getArgb(startX + x1, startY + y0)); + double c01 = grayscale(pixelReader.getArgb(startX + x0, startY + y1)); + double c11 = grayscale(pixelReader.getArgb(startX + x1, startY + y1)); + double top = c00 + (c10 - c00) * tx; + double bottom = c01 + (c11 - c01) * tx; + output[y * targetWidth + x] = (float) (top + (bottom - top) * ty); + } + } + return new HeightField(targetWidth, targetHeight, output); + } + + private static float grayscale(int argb) { + int red = (argb >> 16) & 0xFF; + int green = (argb >> 8) & 0xFF; + int blue = argb & 0xFF; + return (float) (((red + green + blue) / 3.0) / 255.0); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceRenderController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceRenderController.java new file mode 100644 index 00000000..7a4f7204 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceRenderController.java @@ -0,0 +1,558 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.data.messages.xai.ShapleyVector; +import edu.jhuapl.trinity.javafx.javafx3d.Vert3D; +import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; +import edu.jhuapl.trinity.utils.Utils; +import javafx.application.Platform; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.DrawMode; +import org.fxyz3d.geometry.Point3D; + +import java.util.List; +import java.util.function.Function; + +/** + * Owns Hypersurface surface-renderer construction, renderer selection, appearance, + * and LOD activation/orchestration. + * + *

The controller deliberately does not process source data, interpret user + * interaction, manage overlays, or own application controls. It consumes processed + * HeightFields from {@link HypersurfaceSourceModel} and coordinates the legacy/global + * and tiled rendering paths.

+ * + * @author Sean Phillips + */ +public final class HypersurfaceRenderController { + + private static final int DEFAULT_TILE_CELLS_L0 = 256; + private static final int TOTAL_COLORS = 1530; + + private final Hypersurface3DPane owner; + private final HypersurfaceSourceModel sourceModel; + private final HypersurfaceProcessingController processingController; + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay; + private final Runnable coordinateMapperRefresh; + + private final Function heightLookup; + private final Function colorByHeight; + private final Function colorByShapley = p -> p.f; + + private HyperSurfacePlotMesh surfaceMesh; + private LodManager lodManager; + private TiledSurfaceRenderer tiledSurfaceRenderer; + private boolean tiledHeightFieldRenderingEnabled = true; + private int tileCellsL0 = DEFAULT_TILE_CELLS_L0; + + public HypersurfaceRenderController(Hypersurface3DPane owner, + HypersurfaceSourceModel sourceModel, + HypersurfaceProcessingController processingController, + SurfaceCrosshairOverlay surfaceCrosshairOverlay, + Runnable coordinateMapperRefresh) { + if (owner == null) throw new IllegalArgumentException("owner cannot be null"); + if (sourceModel == null) throw new IllegalArgumentException("sourceModel cannot be null"); + if (processingController == null) { + throw new IllegalArgumentException("processingController cannot be null"); + } + if (surfaceCrosshairOverlay == null) { + throw new IllegalArgumentException("surfaceCrosshairOverlay cannot be null"); + } + if (coordinateMapperRefresh == null) { + throw new IllegalArgumentException("coordinateMapperRefresh cannot be null"); + } + this.owner = owner; + this.sourceModel = sourceModel; + this.processingController = processingController; + this.surfaceCrosshairOverlay = surfaceCrosshairOverlay; + this.coordinateMapperRefresh = coordinateMapperRefresh; + this.heightLookup = p -> processingController.sampleHeight( + p, owner.surfaceRender, owner.surfScale, owner.yScale); + this.colorByHeight = p -> sourceModel.getHeightOrientation().toLogicalHeight(p.y); + } + + /** + * Create the legacy/global surface mesh and both LOD render paths. This method is + * idempotent so renderer ownership remains centralized here. + */ + public HyperSurfacePlotMesh initializeRenderers() { + if (surfaceMesh == null) { + surfaceMesh = new HyperSurfacePlotMesh( + owner.xWidth, owner.zWidth, 1, 1, owner.yScale, owner.surfScale, heightLookup); + surfaceMesh.setRowOrientation(sourceModel.getRowOrientation()); + surfaceMesh.setHeightOrientation(sourceModel.getHeightOrientation()); + surfaceMesh.setTextureModeVertices3D( + TOTAL_COLORS, colorByHeight, 0.0, 360.0); + surfaceMesh.setDrawMode(DrawMode.FILL); + surfaceMesh.setCullFace(CullFace.BACK); + surfaceMesh.setTranslateX(-(owner.xWidth * owner.surfScale) / 2.0); + surfaceMesh.setTranslateZ(-(owner.zWidth * owner.surfScale) / 2.0); + owner.sceneRoot.getChildren().add(surfaceMesh); + } + + if (lodManager == null) { + lodManager = new LodManager(owner.camera, owner.subScene, surfaceMesh); + LodManager.LodConfig cfg = new LodManager.LodConfig(); + cfg.targetPixelsPerCell = 1.5; + cfg.lowThreshold = 1.0; + cfg.highThreshold = 2.0; + cfg.throttleMs = 75; + cfg.debounceMs = 75; + lodManager.setConfig(cfg); + lodManager.setOnLodSelected(idx -> { + if (!shouldUseTiledHeightFieldRenderer()) applyActiveLodIndex(idx); + }); + } + + if (tiledSurfaceRenderer == null) { + tiledSurfaceRenderer = new TiledSurfaceRenderer(owner.camera, owner.subScene); + tiledSurfaceRenderer.setRowOrientation(sourceModel.getRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(sourceModel.getHeightOrientation()); + tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); + tiledSurfaceRenderer.setVisible(false); + owner.sceneRoot.getChildren().add(tiledSurfaceRenderer); + } + return surfaceMesh; + } + + public HyperSurfacePlotMesh getSurfaceMesh() { + return surfaceMesh; + } + + public TiledSurfaceRenderer getTiledSurfaceRenderer() { + return tiledSurfaceRenderer; + } + + public double getSurfaceMaxAbsY() { + return surfaceMesh != null ? Math.abs(surfaceMesh.getMaxAbsY()) : 0.0; + } + + public void updateMesh() { + if (surfaceMesh == null) return; + + surfaceCrosshairOverlay.hide(); + owner.sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); + + final boolean useTiledRenderer = owner.surfaceRender && shouldUseTiledHeightFieldRenderer(); + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setVisible(useTiledRenderer); + } + if (useTiledRenderer) { + surfaceMesh.setVisible(false); + configureTiledAppearance(); + tiledSurfaceRenderer.forceUpdate(); + return; + } + + // An image-backed surface with no active HeightField is between source + // replacement and LOD-pyramid activation. Never build a raw full-resolution + // legacy mesh in that transient state. + if (sourceModel.isImageBackedSurface() && sourceModel.getActiveHeightField() == null) { + surfaceMesh.setVisible(false); + return; + } + + surfaceMesh.setVisible(owner.surfaceRender); + + final int renderWidth = sourceModel.getRenderWidth(owner.xWidth); + final int renderHeight = sourceModel.getRenderHeight(owner.zWidth); + final double renderScaleX = sourceModel.getRenderScaleX(owner.surfScale); + final double renderScaleZ = sourceModel.getRenderScaleZ(owner.surfScale); + + if (owner.surfaceRender) { + surfaceMesh.setRowOrientation(sourceModel.getRowOrientation()); + surfaceMesh.setHeightOrientation(sourceModel.getHeightOrientation()); + boolean useDirectHeightFieldMesh = sourceModel.getActiveHeightField() != null + && owner.colorationMethod != Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY; + + long meshStart = System.nanoTime(); + if (useDirectHeightFieldMesh) { + surfaceMesh.updateMeshHeightField( + sourceModel.getActiveHeightField(), renderScaleX, owner.yScale, renderScaleZ); + System.out.println("Direct HeightField mesh built: " + + renderWidth + "x" + renderHeight + " in " + + Utils.totalTimeString(meshStart)); + } else { + surfaceMesh.updateMeshRaw( + renderWidth, renderHeight, renderScaleX, owner.yScale, renderScaleZ); + } + applyCurrentColoration(); + } else { + List> renderGrid = sourceModel.getActiveHeightField() != null + ? SurfaceUtils.toGrid(sourceModel.getActiveHeightField()) + : owner.dataGrid; + TessellationTube tube = new TessellationTube( + renderGrid, Color.WHITE, owner.yScale * 10, renderScaleZ, owner.yScale); + tube.setMouseTransparent(true); + if (sourceModel.getSourceImage() != null) { + tube.meshView.setDrawMode(DrawMode.FILL); + tube.colorByImage = owner.colorationMethod + == Hypersurface3DPane.COLORATION.COLOR_BY_IMAGE; + tube.updateMaterial(sourceModel.getSourceImage()); + } + Platform.runLater(() -> owner.sceneRoot.getChildren().add(tube)); + } + } + + /** Apply only coloration/material state without rebuilding surface geometry. */ + public void applyCurrentColoration() { + if (surfaceMesh == null || owner.colorationMethod == null) return; + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledAppearance(); + return; + } + + switch (owner.colorationMethod) { + case COLOR_BY_IMAGE -> { + if (sourceModel.getSourceImage() == null) return; + if (surfaceMesh.isDirectHeightFieldMesh()) { + surfaceMesh.setDirectTextureModeImage(sourceModel.getSourceImage()); + } else { + PhongMaterial material; + if (surfaceMesh.getMaterial() instanceof PhongMaterial existing) { + material = existing; + } else { + material = new PhongMaterial(Color.WHITE); + } + material.setDiffuseColor(Color.WHITE); + material.setDiffuseMap(sourceModel.getSourceImage()); + surfaceMesh.setMaterial(material); + } + } + case COLOR_BY_FEATURE -> { + if (surfaceMesh.isDirectHeightFieldMesh()) { + HeightField colorRangeField = !sourceModel.getLodProcessedLevels().isEmpty() + ? sourceModel.getLodProcessedLevels().get(0) + : sourceModel.getActiveHeightField(); + if (colorRangeField != null) { + float[] minMax = colorRangeField.minMax(); + double minColorValue = minMax[0] * owner.yScale; + double maxColorValue = minMax[1] * owner.yScale; + if (!(maxColorValue > minColorValue)) { + double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); + minColorValue -= pad; + maxColorValue += pad; + } + surfaceMesh.setDirectTextureModeByHeight( + TOTAL_COLORS, minColorValue, maxColorValue); + } + } else { + surfaceMesh.setTextureModeVertices3D( + TOTAL_COLORS, colorByHeight, 0.0, 360.0); + } + } + case COLOR_BY_SHAPLEY -> surfaceMesh.setTextureModeVertices3D( + TOTAL_COLORS, colorByShapley, 0.0, 360.0); + } + } + + /** + * Apply a coloration-mode transition, including the legacy Shapley geometry path. + */ + public void handleColorationChanged(Hypersurface3DPane.COLORATION previous, + Hypersurface3DPane.COLORATION next) { + boolean shapleyPathChanged = (previous == Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY) + != (next == Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY); + if (!shapleyPathChanged) { + applyCurrentColoration(); + return; + } + + if (next == Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY + && lodManager != null + && sourceModel.getLodProcessedLevels() != null + && !sourceModel.getLodProcessedLevels().isEmpty()) { + sourceModel.setActiveLod(-1, null); + lodManager.setPyramid( + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), -1); + lodManager.forceUpdate(); + } else if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + } + updateMesh(); + } + + public void activateProcessedPyramid() { + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + updateMesh(); + } else if (lodManager != null) { + lodManager.setPyramid( + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), -1); + lodManager.forceUpdate(); + } else { + applyActiveLodIndex(0); + } + } + + public void refreshWorldExtentsAndLodMetadata() { + sourceModel.recomputeBaseWorldExtents(owner.xWidth, owner.zWidth, owner.surfScale); + coordinateMapperRefresh.run(); + + HeightField activeField = sourceModel.getActiveHeightField(); + if (activeField != null) { + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / activeField.width(), + sourceModel.getBaseWorldDepth() / activeField.height()); + } + centerLegacySurfaceOnWorldExtents(); + + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + tiledSurfaceRenderer.forceUpdate(); + } else if (lodManager != null && !sourceModel.getLodProcessedLevels().isEmpty()) { + lodManager.setPyramid( + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), + sourceModel.getBaseWorldDepth(), + sourceModel.getActiveLodIndex()); + lodManager.forceUpdate(); + } + } + + public void updateRawSurfaceTranslation() { + if (surfaceMesh == null || sourceModel.getActiveHeightField() != null) return; + surfaceMesh.setTranslateX(-(owner.xWidth * owner.surfScale) / 2.0); + surfaceMesh.setTranslateZ(-(owner.zWidth * owner.surfScale) / 2.0); + } + + public void centerLegacySurfaceOnWorldExtents() { + if (surfaceMesh == null) return; + surfaceMesh.setTranslateX(-sourceModel.getBaseWorldWidth() / 2.0); + surfaceMesh.setTranslateZ(-sourceModel.getBaseWorldDepth() / 2.0); + } + + public void setFunctionScale(double yScale) { + if (surfaceMesh != null) surfaceMesh.setFunctionScale(yScale); + } + + public void scaleHeight(double factor) { + if (surfaceMesh != null) surfaceMesh.scaleHeight((float) factor); + } + + public void setDrawMode(DrawMode drawMode) { + if (surfaceMesh != null) surfaceMesh.setDrawMode(drawMode); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setDrawMode(drawMode); + } + + public void setCullFace(CullFace cullFace) { + if (surfaceMesh != null) surfaceMesh.setCullFace(cullFace); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setCullFace(cullFace); + } + + public void setSpecularColor(Color color) { + if (surfaceMesh != null && surfaceMesh.getMaterial() instanceof PhongMaterial material) { + material.setSpecularColor(color); + } + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setSpecularColor(color); + } + + public void setRowOrientation(SurfaceRowOrientation orientation) { + if (surfaceMesh != null) surfaceMesh.setRowOrientation(orientation); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setRowOrientation(orientation); + } + + public void setHeightOrientation(SurfaceHeightOrientation orientation) { + if (surfaceMesh != null) surfaceMesh.setHeightOrientation(orientation); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setHeightOrientation(orientation); + } + + public void updateShapleyFunctionValues(List shapleyVectors, double yScale) { + if (surfaceMesh == null) return; + surfaceMesh.functionValues.clear(); + for (ShapleyVector vector : shapleyVectors) { + surfaceMesh.functionValues.add(vector.getData().get(0) * yScale); + } + } + + public void releaseImageBackedRenderStateForReplacement() { + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.clearSurface(); + tiledSurfaceRenderer.setVisible(false); + } + if (surfaceMesh != null) surfaceMesh.setVisible(false); + surfaceCrosshairOverlay.hide(); + } + + public void resetLodRenderState() { + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.clearSurface(); + tiledSurfaceRenderer.setVisible(false); + } + } + + public boolean isTiledHeightFieldRenderingEnabled() { + return tiledHeightFieldRenderingEnabled; + } + + public void setTiledHeightFieldRenderingEnabled(boolean enabled) { + if (tiledHeightFieldRenderingEnabled == enabled) return; + tiledHeightFieldRenderingEnabled = enabled; + if (sourceModel.getLodProcessedLevels() == null + || sourceModel.getLodProcessedLevels().isEmpty()) { + return; + } + + if (enabled && shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + } else if (lodManager != null) { + sourceModel.setActiveLod(-1, null); + lodManager.setPyramid( + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), -1); + lodManager.forceUpdate(); + } + updateMesh(); + } + + public int getTileCellsL0() { + return tileCellsL0; + } + + public void setTileCellsL0(int tileCellsL0) { + if (tileCellsL0 < 16) { + throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + } + if (this.tileCellsL0 == tileCellsL0) return; + this.tileCellsL0 = tileCellsL0; + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + updateMesh(); + } + } + + public TiledLodManager.Config getTiledLodConfigCopy() { + return tiledSurfaceRenderer != null + ? tiledSurfaceRenderer.getLodConfigCopy() + : new TiledLodManager.Config(); + } + + public void setTiledLodConfig(TiledLodManager.Config config) { + if (tiledSurfaceRenderer == null || config == null) return; + tiledSurfaceRenderer.setLodConfig(config); + } + + public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { + return tiledSurfaceRenderer != null + ? tiledSurfaceRenderer.getLodStatistics() + : new TiledSurfaceRenderer.LodStatistics( + 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0, 0); + } + + public boolean shouldUseTiledHeightFieldRenderer() { + return tiledHeightFieldRenderingEnabled + && sourceModel.isImageBackedSurface() + && tiledSurfaceRenderer != null + && sourceModel.getLodProcessedLevels() != null + && !sourceModel.getLodProcessedLevels().isEmpty() + && owner.colorationMethod != Hypersurface3DPane.COLORATION.COLOR_BY_SHAPLEY; + } + + public boolean isTiledHeightFieldRendererActive() { + return shouldUseTiledHeightFieldRenderer() + && owner.surfaceRender + && tiledSurfaceRenderer.isVisible(); + } + + public void requestLodUpdate(LodManager.UpdateReason reason) { + if (shouldUseTiledHeightFieldRenderer()) { + tiledSurfaceRenderer.requestUpdate(reason); + } else if (lodManager != null) { + lodManager.requestUpdate(reason); + } + } + + public void forceLodUpdate() { + if (shouldUseTiledHeightFieldRenderer()) { + tiledSurfaceRenderer.forceUpdate(); + } else if (lodManager != null) { + lodManager.forceUpdate(); + } + } + + private void configureTiledAppearance() { + if (tiledSurfaceRenderer == null) return; + if (surfaceMesh != null) { + tiledSurfaceRenderer.setDrawMode(surfaceMesh.getDrawMode()); + tiledSurfaceRenderer.setCullFace(surfaceMesh.getCullFace()); + if (surfaceMesh.getMaterial() instanceof PhongMaterial material) { + tiledSurfaceRenderer.setSpecularColor(material.getSpecularColor()); + } + } + tiledSurfaceRenderer.setYScale(owner.yScale); + tiledSurfaceRenderer.setRowOrientation(sourceModel.getRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(sourceModel.getHeightOrientation()); + + if (owner.colorationMethod == Hypersurface3DPane.COLORATION.COLOR_BY_IMAGE + && sourceModel.getSourceImage() != null) { + tiledSurfaceRenderer.setColorByImage(sourceModel.getSourceImage()); + } else if (owner.colorationMethod == Hypersurface3DPane.COLORATION.COLOR_BY_FEATURE + && !sourceModel.getLodProcessedLevels().isEmpty()) { + float[] minMax = sourceModel.getLodProcessedLevels().get(0).minMax(); + double minColorValue = minMax[0] * owner.yScale; + double maxColorValue = minMax[1] * owner.yScale; + if (!(maxColorValue > minColorValue)) { + double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); + minColorValue -= pad; + maxColorValue += pad; + } + tiledSurfaceRenderer.setColorByHeight( + TOTAL_COLORS, minColorValue, maxColorValue); + } + } + + private void configureTiledPyramid() { + if (!shouldUseTiledHeightFieldRenderer()) return; + sourceModel.setActiveLod(0, sourceModel.getLodProcessedLevels().get(0)); + owner.xWidth = sourceModel.getActiveHeightField().width(); + owner.zWidth = sourceModel.getActiveHeightField().height(); + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / owner.xWidth, + sourceModel.getBaseWorldDepth() / owner.zWidth); + coordinateMapperRefresh.run(); + + tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); + tiledSurfaceRenderer.setPyramid( + sourceModel.getLodProcessedLevels(), + sourceModel.getBaseWorldWidth(), + sourceModel.getBaseWorldDepth(), + owner.yScale); + configureTiledAppearance(); + tiledSurfaceRenderer.forceUpdate(); + owner.syncGuiControls(); + } + + /** Apply the selected global LOD level from the processed pyramid. */ + private void applyActiveLodIndex(int idx) { + if (sourceModel.getLodProcessedLevels() == null + || sourceModel.getLodProcessedLevels().isEmpty()) return; + if (idx < 0) idx = 0; + if (idx >= sourceModel.getLodProcessedLevels().size()) { + idx = sourceModel.getLodProcessedLevels().size() - 1; + } + + HeightField selected = sourceModel.getLodProcessedLevels().get(idx); + if (idx == sourceModel.getActiveLodIndex() + && sourceModel.getActiveHeightField() == selected) return; + + sourceModel.setActiveLod(idx, selected); + owner.xWidth = selected.width(); + owner.zWidth = selected.height(); + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / owner.xWidth, + sourceModel.getBaseWorldDepth() / owner.zWidth); + coordinateMapperRefresh.run(); + + System.out.println("Applying Hypersurface LOD L" + sourceModel.getActiveLodIndex() + + ": " + owner.xWidth + "x" + owner.zWidth + + ", cellScale=" + sourceModel.getCurrentLodSurfScaleX() + + "x" + sourceModel.getCurrentLodSurfScaleZ()); + + centerLegacySurfaceOnWorldExtents(); + owner.syncGuiControls(); + updateMesh(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceSourceModel.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceSourceModel.java new file mode 100644 index 00000000..4949be60 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceSourceModel.java @@ -0,0 +1,427 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import edu.jhuapl.trinity.data.messages.xai.ShapleyVector; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.image.Image; + +import java.util.ArrayList; +import java.util.List; + +/** + * Owns the data/source state represented by a Hypersurface. + * + *

This model deliberately contains no renderer, scene-graph, control, or LOD + * scheduling behavior. It tracks the source collections/image region, primitive + * HeightField state derived from that source, source/render dimensions, stable + * world extents, and the row/height orientation semantics shared by renderers and + * interaction code.

+ * + * @author Sean Phillips + */ +public final class HypersurfaceSourceModel { + + private final List shapleyVectors = new ArrayList<>(); + private final List featureVectors = new ArrayList<>(); + private final List> dataGrid = new ArrayList<>(); + private final List featureLabels = new ArrayList<>(); + private List> originalGrid = new ArrayList<>(); + + private final ObjectProperty rowOrientation = + new SimpleObjectProperty<>(SurfaceRowOrientation.FIRST_ROW_NEAR); + private final ObjectProperty heightOrientation = + new SimpleObjectProperty<>(SurfaceHeightOrientation.HIGH_VALUES_UP); + private Image sourceImage; + private int imageSourceStartX; + private int imageSourceStartY; + private int imageSourceWidth; + private int imageSourceHeight; + private boolean imageBackedSurface; + + private HeightField processingSourceHeightField; + private List lodProcessedLevels = List.of(); + private int activeLodIndex = -1; + private HeightField activeHeightField; + + private double baseWorldWidth = Double.NaN; + private double baseWorldDepth = Double.NaN; + private double currentLodSurfScaleX = Double.NaN; + private double currentLodSurfScaleZ = Double.NaN; + + private final double imageWorldExtentReferenceScale; + private double nominalImageWorldExtent; + + public HypersurfaceSourceModel(int defaultXWidth, int defaultSurfaceScale) { + imageWorldExtentReferenceScale = Math.max(1.0e-6, defaultSurfaceScale); + nominalImageWorldExtent = defaultXWidth * (double) defaultSurfaceScale; + } + + public List getShapleyVectors() { + return shapleyVectors; + } + + public List getFeatureVectors() { + return featureVectors; + } + + + public List> getDataGrid() { + return dataGrid; + } + + public List getFeatureLabels() { + return featureLabels; + } + + + public List> getOriginalGrid() { + return originalGrid; + } + + public void clearOriginalGrid() { + originalGrid.clear(); + } + + public boolean captureDataGridAsSource() { + boolean wasImageBacked = imageBackedSurface; + imageBackedSurface = false; + if (dataGrid.isEmpty()) { + originalGrid = new ArrayList<>(); + clearPrimitiveSourceState(); + return wasImageBacked; + } + originalGrid = deepCopyGrid(dataGrid); + processingSourceHeightField = null; + clearImageRegion(); + return wasImageBacked; + } + + public void activateImageSource(Image image, + int startX, + int startY, + int width, + int height, + HeightField rawL0) { + sourceImage = image; + setImageSourceRegion(startX, startY, width, height); + processingSourceHeightField = rawL0; + featureVectors.clear(); + dataGrid.clear(); + originalGrid.clear(); + imageBackedSurface = true; + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + invalidateWorldExtents(); + clearCurrentLodSurfScale(); + } + + public Image getSourceImage() { + return sourceImage; + } + + public void setSourceImage(Image sourceImage) { + this.sourceImage = sourceImage; + } + + + + public int getImageSourceStartX() { + return imageSourceStartX; + } + + public int getImageSourceStartY() { + return imageSourceStartY; + } + + public int getImageSourceWidth() { + return imageSourceWidth; + } + + public int getImageSourceHeight() { + return imageSourceHeight; + } + + public void setImageSourceRegion(int startX, int startY, int width, int height) { + imageSourceStartX = startX; + imageSourceStartY = startY; + imageSourceWidth = Math.max(0, width); + imageSourceHeight = Math.max(0, height); + } + + public boolean isImageBackedSurface() { + return imageBackedSurface; + } + + + public HeightField getProcessingSourceHeightField() { + return processingSourceHeightField; + } + + public void setProcessingSourceHeightField(HeightField processingSourceHeightField) { + this.processingSourceHeightField = processingSourceHeightField; + } + + public List getLodProcessedLevels() { + return lodProcessedLevels; + } + + public void setLodProcessedLevels(List lodProcessedLevels) { + this.lodProcessedLevels = lodProcessedLevels != null ? lodProcessedLevels : List.of(); + } + + public int getActiveLodIndex() { + return activeLodIndex; + } + + + public HeightField getActiveHeightField() { + return activeHeightField; + } + + + public void setActiveLod(int activeLodIndex, HeightField activeHeightField) { + this.activeLodIndex = activeLodIndex; + this.activeHeightField = activeHeightField; + } + + public double getBaseWorldWidth() { + return baseWorldWidth; + } + + public double getBaseWorldDepth() { + return baseWorldDepth; + } + + public void invalidateWorldExtents() { + baseWorldWidth = Double.NaN; + baseWorldDepth = Double.NaN; + } + + public double getCurrentLodSurfScaleX() { + return currentLodSurfScaleX; + } + + public double getCurrentLodSurfScaleZ() { + return currentLodSurfScaleZ; + } + + public void setCurrentLodSurfScale(double scaleX, double scaleZ) { + currentLodSurfScaleX = scaleX; + currentLodSurfScaleZ = scaleZ; + } + + public void clearCurrentLodSurfScale() { + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + } + + public double getRenderScaleX(double fallbackScale) { + return Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : fallbackScale; + } + + public double getRenderScaleZ(double fallbackScale) { + return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : fallbackScale; + } + + public int getRenderL0Width(int fallbackWidth) { + return !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0).width() + : getRenderWidth(fallbackWidth); + } + + public int getRenderL0Height(int fallbackHeight) { + return !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0).height() + : getRenderHeight(fallbackHeight); + } + + public int getSourceWidth(int fallbackWidth) { + if (imageBackedSurface && imageSourceWidth > 0) return imageSourceWidth; + if (processingSourceHeightField != null) return processingSourceHeightField.width(); + if (!originalGrid.isEmpty()) return originalGrid.get(0).size(); + if (!dataGrid.isEmpty()) return dataGrid.get(0).size(); + return Math.max(1, fallbackWidth); + } + + public int getSourceHeight(int fallbackHeight) { + if (imageBackedSurface && imageSourceHeight > 0) return imageSourceHeight; + if (processingSourceHeightField != null) return processingSourceHeightField.height(); + if (!originalGrid.isEmpty()) return originalGrid.size(); + if (!dataGrid.isEmpty()) return dataGrid.size(); + return Math.max(1, fallbackHeight); + } + + public int getRenderWidth(int fallbackWidth) { + return activeHeightField != null ? activeHeightField.width() : Math.max(1, fallbackWidth); + } + + public int getRenderHeight(int fallbackHeight) { + return activeHeightField != null ? activeHeightField.height() : Math.max(1, fallbackHeight); + } + + public double getWorldWidth(int fallbackWidth, int fallbackHeight, double surfaceScale) { + if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; + return calculateWorldWidth( + getSourceWidth(fallbackWidth), getSourceHeight(fallbackHeight), surfaceScale); + } + + public double getWorldDepth(int fallbackWidth, int fallbackHeight, double surfaceScale) { + if (Double.isFinite(baseWorldDepth)) return baseWorldDepth; + return calculateWorldDepth( + getSourceWidth(fallbackWidth), getSourceHeight(fallbackHeight), surfaceScale); + } + + public void recomputeBaseWorldExtents(int fallbackWidth, + int fallbackHeight, + double surfaceScale) { + int sourceWidth = getSourceWidth(fallbackWidth); + int sourceHeight = getSourceHeight(fallbackHeight); + baseWorldWidth = calculateWorldWidth(sourceWidth, sourceHeight, surfaceScale); + baseWorldDepth = calculateWorldDepth(sourceWidth, sourceHeight, surfaceScale); + } + + public double getNominalImageWorldExtent() { + return nominalImageWorldExtent; + } + + public void setNominalImageWorldExtent(double nominalImageWorldExtent) { + if (!(nominalImageWorldExtent > 0.0) || !Double.isFinite(nominalImageWorldExtent)) { + throw new IllegalArgumentException("nominalImageWorldExtent must be finite and > 0"); + } + this.nominalImageWorldExtent = nominalImageWorldExtent; + } + + public SurfaceRowOrientation getRowOrientation() { + return rowOrientation.get(); + } + + public void setRowOrientation(SurfaceRowOrientation orientation) { + rowOrientation.set(orientation != null + ? orientation + : SurfaceRowOrientation.FIRST_ROW_NEAR); + } + + public ObjectProperty rowOrientationProperty() { + return rowOrientation; + } + + public SurfaceHeightOrientation getHeightOrientation() { + return heightOrientation.get(); + } + + public void setHeightOrientation(SurfaceHeightOrientation orientation) { + heightOrientation.set(orientation != null + ? orientation + : SurfaceHeightOrientation.HIGH_VALUES_UP); + } + + public ObjectProperty heightOrientationProperty() { + return heightOrientation; + } + + + public int orientRowIndex(int logicalRow, int rowCount) { + if (rowCount <= 0) return 0; + int clamped = Math.max(0, Math.min(logicalRow, rowCount - 1)); + return getRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR + ? rowCount - 1 - clamped + : clamped; + } + + public double orientRowCoordinate(double logicalRow, int rowCount) { + if (rowCount <= 1) return 0.0; + double clamped = Math.max(0.0, Math.min(logicalRow, rowCount - 1.0)); + return getRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR + ? (rowCount - 1.0) - clamped + : clamped; + } + + public double getActiveRenderValue(int row, int column) { + if (activeHeightField == null) return 0.0; + int r = orientRowIndex(row, activeHeightField.height()); + int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); + return activeHeightField.get(c, r); + } + + public List getActiveRenderRow(int row) { + if (activeHeightField == null) return List.of(); + int r = orientRowIndex(row, activeHeightField.height()); + int width = activeHeightField.width(); + float[] values = activeHeightField.data(); + List out = new ArrayList<>(width); + int offset = r * width; + for (int x = 0; x < width; x++) out.add((double) values[offset + x]); + return out; + } + + public Double[] getActiveRenderColumn(int column) { + if (activeHeightField == null) return new Double[0]; + int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); + int width = activeHeightField.width(); + int height = activeHeightField.height(); + float[] values = activeHeightField.data(); + Double[] out = new Double[height]; + for (int visualRow = 0; visualRow < height; visualRow++) { + int sourceRow = orientRowIndex(visualRow, height); + out[visualRow] = (double) values[sourceRow * width + c]; + } + return out; + } + + /** Clears primitive/image/LOD state without changing the source collections. */ + public void clearPrimitiveSourceState() { + processingSourceHeightField = null; + clearImageRegion(); + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + invalidateWorldExtents(); + clearCurrentLodSurfScale(); + } + + /** Clears the previous image reference and all render-side state derived from it. */ + public void releaseImageBackedStateForReplacement() { + clearPrimitiveSourceState(); + sourceImage = null; + } + + public void resetLodDataState() { + imageBackedSurface = false; + clearPrimitiveSourceState(); + } + + private void clearImageRegion() { + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; + } + + private double calculateWorldWidth(int sourceWidth, int sourceHeight, double surfaceScale) { + if (!imageBackedSurface) return sourceWidth * surfaceScale; + if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; + double longAxis = imageWorldLongAxisExtent(surfaceScale); + if (sourceWidth >= sourceHeight) return longAxis; + return longAxis * sourceWidth / (double) sourceHeight; + } + + private double calculateWorldDepth(int sourceWidth, int sourceHeight, double surfaceScale) { + if (!imageBackedSurface) return sourceHeight * surfaceScale; + if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; + double longAxis = imageWorldLongAxisExtent(surfaceScale); + if (sourceHeight >= sourceWidth) return longAxis; + return longAxis * sourceHeight / (double) sourceWidth; + } + + private double imageWorldLongAxisExtent(double surfaceScale) { + return nominalImageWorldExtent * (surfaceScale / imageWorldExtentReferenceScale); + } + + private static List> deepCopyGrid(List> src) { + List> out = new ArrayList<>(src.size()); + for (List row : src) out.add(new ArrayList<>(row)); + return out; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodManager.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodManager.java index dbc8b00f..b9f90d8c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodManager.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.animation.PauseTransition; import javafx.application.Platform; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodTransition.java similarity index 95% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodTransition.java index 992b28d4..4df72aec 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/LodTransition.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * One requested tile LOD transition, ordered by rendering priority. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java new file mode 100644 index 00000000..01a9528d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java @@ -0,0 +1,34 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import javafx.scene.image.PixelWriter; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; + +/** + * Shared color palette used by height-based Hypersurface rendering and its legend. + */ +public final class SurfaceColorPalette { + + private SurfaceColorPalette() { + } + + public static Color colorAt(double normalized) { + double value = Math.max(0.0, Math.min(1.0, normalized)); + if (value <= 0.0) return Color.BLACK; + if (value >= 1.0) return Color.WHITE; + return Color.hsb(360.0 * value, 1.0, 1.0, 1.0); + } + + public static WritableImage createPaletteImage(int colors) { + if (colors < 2) { + throw new IllegalArgumentException("colors must be >= 2"); + } + WritableImage palette = new WritableImage(colors, 1); + PixelWriter writer = palette.getPixelWriter(); + for (int i = 0; i < colors; i++) { + double normalized = (double) i / (double) (colors - 1); + writer.setColor(i, 0, colorAt(normalized)); + } + return palette; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java new file mode 100644 index 00000000..373e2310 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java @@ -0,0 +1,196 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +/** + * Centralizes coordinate conversion between source data, the active render grid, + * and the centered world coordinates used by the Hypersurface scene. + * + *

The mapper deliberately keeps source dimensions independent from render + * dimensions. This matters for image-backed surfaces where an 8K source may be + * rendered from a capped 2K L0 HeightField while preserving the original source + * row/column semantics.

+ * + * @author Sean Phillips + */ +public final class SurfaceCoordinateMapper { + + private int sourceWidth = 1; + private int sourceHeight = 1; + private int renderWidth = 1; + private int renderHeight = 1; + private double worldWidth = 1.0; + private double worldDepth = 1.0; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + + public void configure(int sourceWidth, + int sourceHeight, + int renderWidth, + int renderHeight, + double worldWidth, + double worldDepth, + SurfaceRowOrientation rowOrientation) { + this.sourceWidth = Math.max(1, sourceWidth); + this.sourceHeight = Math.max(1, sourceHeight); + this.renderWidth = Math.max(1, renderWidth); + this.renderHeight = Math.max(1, renderHeight); + this.worldWidth = sanitizeExtent(worldWidth); + this.worldDepth = sanitizeExtent(worldDepth); + this.rowOrientation = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + } + + public double sourceColumnToWorldX(int sourceColumn) { + return sourceColumnToSurfaceX(sourceColumn) - worldWidth / 2.0; + } + + public int worldXToSourceColumn(double worldX) { + return surfaceXToSourceColumn(worldX + worldWidth / 2.0); + } + + public double sourceRowToWorldZ(int sourceRow) { + return sourceRowToSurfaceZ(sourceRow) - worldDepth / 2.0; + } + + public int worldZToSourceRow(double worldZ) { + return surfaceZToSourceRow(worldZ + worldDepth / 2.0); + } + + /** + * Converts a source column to the uncentered surface-local X coordinate used + * by HyperSurfacePlotMesh and TiledSurfaceRenderer before their root translation. + */ + public double sourceColumnToSurfaceX(int sourceColumn) { + int clamped = clampIndex(sourceColumn, sourceWidth); + return clamped * sourceCellWidth(); + } + + public int surfaceXToSourceColumn(double surfaceX) { + int visualColumn = coordinateToIndex(surfaceX, worldWidth, sourceWidth); + return clampIndex(visualColumn, sourceWidth); + } + + /** + * Converts a source row to uncentered surface-local Z. Row orientation is + * applied here, so source row zero maps to either the near or far edge. + */ + public double sourceRowToSurfaceZ(int sourceRow) { + int clamped = clampIndex(sourceRow, sourceHeight); + int visualRow = orientSourceRowToVisualRow(clamped); + return visualRow * sourceCellDepth(); + } + + public int surfaceZToSourceRow(double surfaceZ) { + int visualRow = coordinateToIndex(surfaceZ, worldDepth, sourceHeight); + return orientVisualRowToSourceRow(visualRow); + } + + /** Render-grid indices are visual indices and therefore are not row-flipped. */ + public int surfaceXToRenderColumn(double surfaceX) { + return coordinateToIndex(surfaceX, worldWidth, renderWidth); + } + + /** Render-grid indices are visual indices and therefore are not row-flipped. */ + public int surfaceZToRenderRow(double surfaceZ) { + return coordinateToIndex(surfaceZ, worldDepth, renderHeight); + } + + public int renderColumnToSourceColumn(int renderColumn) { + return mapIndex(clampIndex(renderColumn, renderWidth), renderWidth, sourceWidth); + } + + public int renderRowToSourceRow(int renderRow) { + int visualRenderRow = clampIndex(renderRow, renderHeight); + int visualSourceRow = mapIndex(visualRenderRow, renderHeight, sourceHeight); + return orientVisualRowToSourceRow(visualSourceRow); + } + + /** + * Maps an L0/render-grid column boundary in [0, renderWidth] to the + * corresponding source-grid boundary in [0, sourceWidth]. Shared tile + * boundaries therefore remain contiguous after source scaling. + */ + public int renderColumnBoundaryToSourceBoundary(int renderBoundary) { + int clamped = Math.max(0, Math.min(renderBoundary, renderWidth)); + return (int) Math.round(clamped * sourceWidth / (double) renderWidth); + } + + /** + * Maps an L0/render-grid visual row boundary to the corresponding source-row + * boundary. FIRST_ROW_FAR reverses the boundary direction so consumers can + * use the returned values directly in normal top-to-bottom source-image space. + */ + public int renderRowBoundaryToSourceBoundary(int renderBoundary) { + int clamped = Math.max(0, Math.min(renderBoundary, renderHeight)); + int visualSourceBoundary = (int) Math.round( + clamped * sourceHeight / (double) renderHeight); + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - visualSourceBoundary + : visualSourceBoundary; + } + + public int sourceWidth() { + return sourceWidth; + } + + public int sourceHeight() { + return sourceHeight; + } + + public int renderWidth() { + return renderWidth; + } + + public int renderHeight() { + return renderHeight; + } + + public double worldWidth() { + return worldWidth; + } + + public double worldDepth() { + return worldDepth; + } + + private double sourceCellWidth() { + return worldWidth / sourceWidth; + } + + private double sourceCellDepth() { + return worldDepth / sourceHeight; + } + + private int orientSourceRowToVisualRow(int sourceRow) { + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - 1 - sourceRow + : sourceRow; + } + + private int orientVisualRowToSourceRow(int visualRow) { + int clamped = clampIndex(visualRow, sourceHeight); + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - 1 - clamped + : clamped; + } + + private static int coordinateToIndex(double coordinate, double extent, int size) { + if (size <= 1 || !(extent > 0.0) || !Double.isFinite(coordinate)) return 0; + double clamped = Math.max(0.0, Math.min(coordinate, Math.nextDown(extent))); + int index = (int) Math.floor(clamped * size / extent); + return clampIndex(index, size); + } + + private static int mapIndex(int index, int fromSize, int toSize) { + if (toSize <= 1 || fromSize <= 1) return 0; + double targetCoordinate = ((index + 0.5) * toSize / (double) fromSize) - 0.5; + return clampIndex((int) Math.round(targetCoordinate), toSize); + } + + private static int clampIndex(int index, int size) { + return Math.max(0, Math.min(index, Math.max(1, size) - 1)); + } + + private static double sanitizeExtent(double extent) { + return Double.isFinite(extent) && extent > 0.0 ? extent : 1.0; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java new file mode 100644 index 00000000..188fbca5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java @@ -0,0 +1,424 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import javafx.animation.AnimationTimer; +import javafx.scene.Group; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.MeshView; +import javafx.scene.shape.TriangleMesh; + +/** + * Lightweight reusable crosshair overlay for Hypersurface height fields. + * + *

The overlay owns the full crosshair rendering lifecycle: active surface + * context, pending target, fixed-rate JavaFX pulse coalescing, dirty-state + * tracking, and the two reusable surface-following ribbon meshes. Callers + * provide semantic position requests; rapid requests are collapsed to the + * newest target and mesh updates are capped at 30 Hz.

+ * + *

Row and column ribbons are tracked independently. Moving only the row, + * which is the common graph-hover case, updates only the row mesh point buffer + * while the unchanged column mesh is left untouched.

+ * + * @author Sean Phillips + */ +public final class SurfaceCrosshairOverlay extends Group { + + private static final double MIN_WORLD_THICKNESS = 0.35; + private static final double THICKNESS_CELL_FRACTION = 0.35; + private static final double MIN_LIFT = 0.20; + + /** + * Maximum crosshair mesh-update rate. Mouse/graph position requests may + * arrive much faster; only the newest pending target is consumed when this + * interval has elapsed. This bounds TriangleMesh point-buffer churn while + * retaining latest-position-wins behavior. + */ + private static final int CROSSHAIR_UPDATE_HZ = 30; + private static final long CROSSHAIR_UPDATE_INTERVAL_NANOS = + 1_000_000_000L / CROSSHAIR_UPDATE_HZ; + + private enum RequestSpace { + NONE, + RENDER, + SOURCE + } + + private final TriangleMesh rowMesh = new TriangleMesh(); + private final TriangleMesh columnMesh = new TriangleMesh(); + private final MeshView rowView = new MeshView(rowMesh); + private final MeshView columnView = new MeshView(columnMesh); + private final PhongMaterial material = new PhongMaterial(Color.WHITE); + + private final AnimationTimer updateTimer = new AnimationTimer() { + @Override + public void handle(long now) { + if (!updatePending) { + stop(); + return; + } + + if (lastUpdateNanos != 0L + && now - lastUpdateNanos < CROSSHAIR_UPDATE_INTERVAL_NANOS) { + return; + } + + lastUpdateNanos = now; + applyPendingUpdate(); + + if (!updatePending) stop(); + } + }; + + private HeightField heightField; + private SurfaceCoordinateMapper coordinateMapper; + private double worldWidth = 1.0; + private double worldDepth = 1.0; + private double yScale = 1.0; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + private SurfaceHeightOrientation heightOrientation = SurfaceHeightOrientation.HIGH_VALUES_UP; + + private int topologyWidth = -1; + private int topologyHeight = -1; + private float[] rowPoints = new float[0]; + private float[] columnPoints = new float[0]; + + private RequestSpace requestSpace = RequestSpace.NONE; + private int requestedRow = -1; + private int requestedColumn = -1; + private boolean updatePending = false; + private long lastUpdateNanos = 0L; + + private int renderedVisualRow = -1; + private int renderedVisualColumn = -1; + private boolean rowGeometryDirty = true; + private boolean columnGeometryDirty = true; + + public SurfaceCrosshairOverlay() { + material.setDiffuseColor(Color.WHITE); + material.setSpecularColor(Color.TRANSPARENT); + + configureView(rowView); + configureView(columnView); + getChildren().addAll(rowView, columnView); + setMouseTransparent(true); + setVisible(false); + } + + private void configureView(MeshView view) { + view.setMaterial(material); + view.setCullFace(CullFace.NONE); + view.setMouseTransparent(true); + } + + public void setColor(Color color) { + Color resolved = color != null ? color : Color.WHITE; + material.setDiffuseColor(resolved); + } + + /** + * Configures the surface context used by future crosshair requests. + * + *

If the geometry context changes while a crosshair request is active, + * the current semantic request is reapplied on the next permitted crosshair update. This + * keeps source-space requests correct across orientation, scale, and LOD + * changes without exposing that bookkeeping to Hypersurface3DPane.

+ */ + public void configureSurface(HeightField heightField, + SurfaceCoordinateMapper coordinateMapper, + double yScale, + SurfaceRowOrientation rowOrientation, + SurfaceHeightOrientation heightOrientation) { + if (heightField == null || coordinateMapper == null + || heightField.width() <= 0 || heightField.height() <= 0) { + clearSurface(); + return; + } + + double newWorldWidth = coordinateMapper.worldWidth(); + double newWorldDepth = coordinateMapper.worldDepth(); + SurfaceRowOrientation newRowOrientation = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + SurfaceHeightOrientation newHeightOrientation = heightOrientation != null + ? heightOrientation + : SurfaceHeightOrientation.HIGH_VALUES_UP; + + boolean geometryChanged = this.heightField != heightField + || this.coordinateMapper != coordinateMapper + || Double.compare(this.worldWidth, newWorldWidth) != 0 + || Double.compare(this.worldDepth, newWorldDepth) != 0 + || Double.compare(this.yScale, yScale) != 0 + || this.rowOrientation != newRowOrientation + || this.heightOrientation != newHeightOrientation; + + this.heightField = heightField; + this.coordinateMapper = coordinateMapper; + this.worldWidth = sanitizeExtent(newWorldWidth); + this.worldDepth = sanitizeExtent(newWorldDepth); + this.yScale = Double.isFinite(yScale) ? yScale : 1.0; + this.rowOrientation = newRowOrientation; + this.heightOrientation = newHeightOrientation; + + if (geometryChanged) { + rowGeometryDirty = true; + columnGeometryDirty = true; + if (requestSpace != RequestSpace.NONE) scheduleUpdate(); + } + } + + /** + * Requests a crosshair using visual render-grid indices. + * Intermediate requests are coalesced to the newest target. Mesh updates + * are rate-limited by {@link #CROSSHAIR_UPDATE_HZ}. + */ + public void requestRenderPosition(int visualRow, int visualColumn) { + if (!hasSurface()) { + hide(); + return; + } + requestSpace = RequestSpace.RENDER; + requestedRow = visualRow; + requestedColumn = visualColumn; + scheduleUpdate(); + } + + /** + * Requests a crosshair using source-data row/column coordinates. + */ + public void requestSourcePosition(int sourceRow, int sourceColumn) { + if (!hasSurface()) { + hide(); + return; + } + requestSpace = RequestSpace.SOURCE; + requestedRow = sourceRow; + requestedColumn = sourceColumn; + scheduleUpdate(); + } + + /** + * Requests a source row with the perpendicular crosshair at the source center. + * This is the graph-node highlighting path. + */ + public void requestSourceRow(int sourceRow) { + if (!hasSurface()) { + hide(); + return; + } + requestSourcePosition(sourceRow, coordinateMapper.sourceWidth() / 2); + } + + /** + * Cancels any pending update and hides the overlay. Surface configuration is + * retained so a later position request can be serviced without reconfiguration. + */ + public void hide() { + updatePending = false; + requestSpace = RequestSpace.NONE; + requestedRow = -1; + requestedColumn = -1; + updateTimer.stop(); + lastUpdateNanos = 0L; + setVisible(false); + } + + /** + * Releases the active surface context and invalidates rendered geometry. + */ + public void clearSurface() { + hide(); + heightField = null; + coordinateMapper = null; + renderedVisualRow = -1; + renderedVisualColumn = -1; + rowGeometryDirty = true; + columnGeometryDirty = true; + } + + private void scheduleUpdate() { + updatePending = true; + updateTimer.start(); + } + + private void applyPendingUpdate() { + if (!updatePending) return; + updatePending = false; + + if (!hasSurface() || requestSpace == RequestSpace.NONE) { + setVisible(false); + return; + } + + int visualRow; + int visualColumn; + if (requestSpace == RequestSpace.SOURCE) { + double surfaceZ = coordinateMapper.sourceRowToSurfaceZ(requestedRow); + double surfaceX = coordinateMapper.sourceColumnToSurfaceX(requestedColumn); + visualRow = coordinateMapper.surfaceZToRenderRow(surfaceZ); + visualColumn = coordinateMapper.surfaceXToRenderColumn(surfaceX); + } else { + visualRow = requestedRow; + visualColumn = requestedColumn; + } + + final int width = heightField.width(); + final int height = heightField.height(); + ensureTopology(width, height); + + final int row = clamp(visualRow, height); + final int column = clamp(visualColumn, width); + final double scaleX = worldWidth / width; + final double scaleZ = worldDepth / height; + final double halfRowThickness = Math.max( + MIN_WORLD_THICKNESS, + scaleZ * THICKNESS_CELL_FRACTION) * 0.5; + final double halfColumnThickness = Math.max( + MIN_WORLD_THICKNESS, + scaleX * THICKNESS_CELL_FRACTION) * 0.5; + final double lift = Math.max(MIN_LIFT, Math.abs(yScale) * 0.02); + + if (rowGeometryDirty || row != renderedVisualRow) { + updateRowPoints(heightField, row, scaleX, scaleZ, + halfRowThickness, lift); + rowMesh.getPoints().set(0, rowPoints, 0, rowPoints.length); + renderedVisualRow = row; + rowGeometryDirty = false; + } + + if (columnGeometryDirty || column != renderedVisualColumn) { + updateColumnPoints(heightField, column, scaleX, scaleZ, + halfColumnThickness, lift); + columnMesh.getPoints().set(0, columnPoints, 0, columnPoints.length); + renderedVisualColumn = column; + columnGeometryDirty = false; + } + + setVisible(true); + } + + private void updateRowPoints(HeightField heightField, + int visualRow, + double scaleX, + double scaleZ, + double halfThickness, + double lift) { + final int width = heightField.width(); + final int sourceRow = orientVisualRow(visualRow, heightField.height(), rowOrientation); + final int rowOffset = sourceRow * width; + final float[] data = heightField.data(); + final double zCenter = visualRow * scaleZ - worldDepth / 2.0; + + int out = 0; + for (int vx = 0; vx <= width; vx++) { + int sampleX = Math.min(vx, width - 1); + double x = vx * scaleX - worldWidth / 2.0; + double y = heightOrientation.toWorldY(data[rowOffset + sampleX], yScale) - lift; + + rowPoints[out++] = (float) x; + rowPoints[out++] = (float) y; + rowPoints[out++] = (float) (zCenter - halfThickness); + + rowPoints[out++] = (float) x; + rowPoints[out++] = (float) y; + rowPoints[out++] = (float) (zCenter + halfThickness); + } + } + + private void updateColumnPoints(HeightField heightField, + int visualColumn, + double scaleX, + double scaleZ, + double halfThickness, + double lift) { + final int width = heightField.width(); + final int height = heightField.height(); + final float[] data = heightField.data(); + final double xCenter = visualColumn * scaleX - worldWidth / 2.0; + + int out = 0; + for (int vz = 0; vz <= height; vz++) { + int visualSampleRow = Math.min(vz, height - 1); + int sourceRow = orientVisualRow(visualSampleRow, height, rowOrientation); + double z = vz * scaleZ - worldDepth / 2.0; + double y = heightOrientation.toWorldY( + data[sourceRow * width + visualColumn], yScale) - lift; + + columnPoints[out++] = (float) (xCenter - halfThickness); + columnPoints[out++] = (float) y; + columnPoints[out++] = (float) z; + + columnPoints[out++] = (float) (xCenter + halfThickness); + columnPoints[out++] = (float) y; + columnPoints[out++] = (float) z; + } + } + + private void ensureTopology(int width, int height) { + if (width == topologyWidth && height == topologyHeight) return; + + topologyWidth = width; + topologyHeight = height; + rowPoints = new float[(width + 1) * 2 * 3]; + columnPoints = new float[(height + 1) * 2 * 3]; + + rowMesh.getPoints().setAll(rowPoints); + columnMesh.getPoints().setAll(columnPoints); + rowMesh.getTexCoords().setAll(0.0f, 0.0f); + columnMesh.getTexCoords().setAll(0.0f, 0.0f); + rowMesh.getFaces().setAll(buildRibbonFaces(width)); + columnMesh.getFaces().setAll(buildRibbonFaces(height)); + + renderedVisualRow = -1; + renderedVisualColumn = -1; + rowGeometryDirty = true; + columnGeometryDirty = true; + } + + private boolean hasSurface() { + return heightField != null + && coordinateMapper != null + && heightField.width() > 0 + && heightField.height() > 0 + && worldWidth > 0.0 + && worldDepth > 0.0; + } + + private static int[] buildRibbonFaces(int segments) { + int[] faces = new int[segments * 2 * 6]; + int out = 0; + for (int i = 0; i < segments; i++) { + int a = i * 2; + int b = a + 1; + int c = a + 2; + int d = a + 3; + + faces[out++] = a; faces[out++] = 0; + faces[out++] = c; faces[out++] = 0; + faces[out++] = d; faces[out++] = 0; + + faces[out++] = d; faces[out++] = 0; + faces[out++] = b; faces[out++] = 0; + faces[out++] = a; faces[out++] = 0; + } + return faces; + } + + private static int orientVisualRow(int visualRow, + int height, + SurfaceRowOrientation orientation) { + int clamped = clamp(visualRow, height); + return orientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? height - 1 - clamped + : clamped; + } + + private static int clamp(int value, int size) { + return Math.max(0, Math.min(value, Math.max(1, size) - 1)); + } + + private static double sanitizeExtent(double extent) { + return Double.isFinite(extent) && extent > 0.0 ? extent : 1.0; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java similarity index 96% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java index 58cab0cd..57e91695 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Controls how scalar surface height values map onto the JavaFX Y axis. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java new file mode 100644 index 00000000..48aacda4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java @@ -0,0 +1,52 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +/** + * Presentation-ready inspection state for a hovered Hypersurface location. + * + *

All source/image coordinates are resolved before publication so consumers such + * as Content Navigator do not need to understand LOD, row orientation, or tile + * layout. Tile maximum coordinates are exclusive bounds.

+ * + * @author Sean Phillips + */ +public record SurfaceInspection( + boolean imageBacked, + int sourceWidth, + int sourceHeight, + int renderWidth, + int renderHeight, + int renderColumn, + int renderRow, + int sourceColumn, + int sourceRow, + int imageColumn, + int imageRow, + double surfaceValue, + double worldX, + double worldY, + double worldZ, + double rowMinimum, + double rowMaximum, + double columnMinimum, + double columnMaximum, + int red, + int green, + int blue, + double imageIntensity, + int tileId, + int activeLod, + int tileImageMinX, + int tileImageMinY, + int tileImageMaxXExclusive, + int tileImageMaxYExclusive +) { + public boolean hasImageSample() { + return imageBacked && red >= 0 && green >= 0 && blue >= 0; + } + + public boolean hasTile() { + return tileId >= 0 + && tileImageMaxXExclusive > tileImageMinX + && tileImageMaxYExclusive > tileImageMinY; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java similarity index 94% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java index a9275df1..dee022cb 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Controls how source rows map onto the Hypersurface Z axis. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java index 9e652e87..ca614c31 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import java.util.ArrayList; import java.util.Collections; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java similarity index 97% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java index 6e012c2b..79383ed5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Persistent JavaFX render state for one tiled hypersurface region. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java index 655df7f9..7d06a596 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.animation.AnimationTimer; import javafx.animation.PauseTransition; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java similarity index 92% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java index c0eca711..3b1d8ac1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.scene.Group; import javafx.scene.PerspectiveCamera; @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Tiled renderer for primitive HeightField LOD pyramids. @@ -38,6 +39,16 @@ public record LodStatistics( int pendingTransitions ) { } + /** Read-only logical L0 tile information used by surface inspection UI. */ + public record TileInspection( + int tileId, + int activeLod, + int startColumn, + int endColumnExclusive, + int startRow, + int endRowExclusive + ) { } + public enum ColorMode { IMAGE, HEIGHT @@ -295,6 +306,38 @@ public int getPendingTransitionCount() { return lodManager.getPendingTransitionCount(); } + /** + * Returns the logical L0 tile containing the supplied visual render cell. + * Tile geometry/LOD ownership stays inside this renderer; callers receive only + * immutable bounds and the currently active LOD. + */ + public Optional getTileInspectionAt(int renderRow, int renderColumn) { + if (levels.isEmpty() || tilesById.length == 0) return Optional.empty(); + + HeightField l0 = levels.get(0); + int width = l0.width(); + int height = l0.height(); + if (width <= 0 || height <= 0) return Optional.empty(); + + int column = Math.max(0, Math.min(renderColumn, width - 1)); + int row = Math.max(0, Math.min(renderRow, height - 1)); + int tileColumns = Math.max(1, (int) Math.ceil(width / (double) tileCellsL0)); + int tileColumn = column / tileCellsL0; + int tileRow = row / tileCellsL0; + int tileId = tileRow * tileColumns + tileColumn; + if (tileId < 0 || tileId >= tilesById.length) return Optional.empty(); + + TileRenderState tile = tilesById[tileId]; + if (tile == null) return Optional.empty(); + return Optional.of(new TileInspection( + tile.getTileId(), + tile.getActiveLod(), + tile.startX0, + tile.endX0, + tile.startZ0, + tile.endZ0)); + } + public LodStatistics getLodStatistics() { int visible = 0; int lod0 = 0; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/images/SolidColorTexture.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/images/SolidColorTexture.java new file mode 100644 index 00000000..2736b6be --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/images/SolidColorTexture.java @@ -0,0 +1,35 @@ +package edu.jhuapl.trinity.javafx.javafx3d.images; + +import javafx.scene.image.Image; +import javafx.scene.image.WritableImage; +import javafx.scene.paint.Color; + +/** + * SolidColorTexture + * + * Builds a 1x1 {@link Image} filled with a single {@link Color}, for use as a + * material's self-illumination map. A self-illumination map with transparent + * pixels, paired with a null diffuse color, is this engine's established way + * to fake a flat, unlit, semi-transparent glow - {@code Node.setOpacity()} is + * ignored in 3D subscenes, so this is the substitute. + * + *

No animation, no ramp, no state: this returns one static image per call, + * intended for parts whose emissive color never changes at runtime (e.g. a + * static laser sight). For a part whose glow color or intensity changes over + * time, see {@code LaserEmitterEntity}'s heat-glow LUT instead - that class + * pre-builds a strip of these same 1x1 images and swaps between them. + */ +public final class SolidColorTexture { + + private SolidColorTexture() { + } + + /** Returns a fresh 1x1 image filled with {@code color}, alpha included. + * @param color + * @return */ + public static Image of(Color color) { + WritableImage img = new WritableImage(1, 1); + img.getPixelWriter().setColor(0, 0, color); + return img; + } +} \ No newline at end of file diff --git a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java index 636a3ed0..2750c8a1 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java @@ -8,6 +8,7 @@ import edu.jhuapl.trinity.javafx.events.TerrainEvent; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.utils.JavaFX3DUtils; import edu.jhuapl.trinity.utils.ResourceUtils; import edu.jhuapl.trinity.utils.Utils; diff --git a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java index 3aa79a58..3f428a3f 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java @@ -4,6 +4,7 @@ import edu.jhuapl.trinity.javafx.events.ImageEvent; import edu.jhuapl.trinity.javafx.events.TerrainEvent; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.animation.AnimationTimer; import javafx.application.Application;