From b552b6ec732010a8c3dcab0a90e88cbccf35b91f Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Wed, 7 Oct 2026 18:37:44 -0400 Subject: [PATCH 01/10] Lighting controls fixes --- .../panes/HypersurfaceControlsPane.java | 17 ++-- .../javafx/events/HypersurfaceEvent.java | 6 ++ .../javafx/javafx3d/Hypersurface3DPane.java | 79 ++++++++++--------- 3 files changed, 54 insertions(+), 48 deletions(-) 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..05a80c98 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 @@ -84,7 +84,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private CheckBox enableAmbientCheck; private ColorPicker ambientColorPicker; private CheckBox enablePointCheck; - private ColorPicker specularColorPicker; + private ColorPicker pointColorPicker; // Graph overlay visibility private ToggleButton showGraphToggle; @@ -321,19 +321,18 @@ 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()))); // --- Graph Overlay visibility --- GridPane graphOverlayGrid = formGrid(); 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..2e814a57 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 @@ -215,6 +217,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); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 3f63e5b9..db977822 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -150,6 +150,8 @@ public class Hypersurface3DPane extends StackPane 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 CAMERA_ZOOM_STEP = 50.0; public PerspectiveCamera camera; public CameraTransformer cameraTransform = new CameraTransformer(); @@ -271,6 +273,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} SurfaceChartPane surfaceChartPane; public AmbientLight ambientLight; public PointLight pointLight; + private AmbientLight skyboxAmbientLight; private List> originalGrid = new ArrayList<>(); @@ -443,19 +446,10 @@ public Hypersurface3DPane(Scene scene) { 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); -}); + pointLight.translateXProperty().bind(camera.translateXProperty()); + pointLight.translateYProperty().bind(camera.translateYProperty()); + pointLight.translateZProperty().bind( + camera.translateZProperty().add(POINT_LIGHT_CAMERA_OFFSET_Z)); subScene.setOnKeyPressed(event -> { KeyCode keycode = event.getCode(); @@ -554,27 +548,20 @@ public Hypersurface3DPane(Scene scene) { 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.setOnZoom(event -> { + double deltaZ = event.getZoomFactor() > 1.0 + ? CAMERA_ZOOM_STEP + : -CAMERA_ZOOM_STEP; + zoomCamera(deltaZ); + event.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { double modifier = 50.0; double modifierFactor = 0.1; - if (event.isControlDown()) modifier = 1; + if (event.isControlDown()) modifier = 1.0; 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); -}); + zoomCamera(event.getDeltaY() * modifierFactor * modifier); + }); subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); Pane pathPane = App.getAppPathPaneStack(); @@ -1365,7 +1352,9 @@ private void setupSkyBox() { double size = 100000D; skybox = new Skybox(top, bottom, left, right, front, back, size, camera); sceneRoot.getChildren().add(skybox); - ambientLight.getScope().addAll(skybox); + skyboxAmbientLight = new AmbientLight(Color.WHITE); + skyboxAmbientLight.getScope().add(skybox); + sceneRoot.getChildren().add(skyboxAmbientLight); skybox.setVisible(false); } @@ -1420,9 +1409,18 @@ private void mouseDragCamera(MouseEvent me) { cameraTransform.t.setX(cameraTransform.t.getX() + mouseDeltaX * modifierFactor * modifier * 0.3); cameraTransform.t.setY(cameraTransform.t.getY() + mouseDeltaY * modifierFactor * modifier * 0.3); } + refreshAfterCameraInteraction(LodManager.UpdateReason.DRAG); + } + + private void zoomCamera(double deltaZ) { + camera.setTranslateZ(camera.getTranslateZ() + deltaZ); + refreshAfterCameraInteraction(LodManager.UpdateReason.SCROLL); + } + + private void refreshAfterCameraInteraction(LodManager.UpdateReason reason) { updateLabels(); updateCalloutHeadPoints(subScene); - requestLodUpdate(LodManager.UpdateReason.DRAG); + requestLodUpdate(reason); } private void updateLabels() { @@ -1638,6 +1636,11 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, Point3D vertP3D; + private void configureLightingScopes() { + pointLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); + ambientLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); + } + private void loadSurf3D() { LOG.info("Rendering Hypersurface Mesh..."); generateRandos(xWidth, zWidth, yScale); @@ -1761,12 +1764,7 @@ private void loadSurf3D() { 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); + configureLightingScopes(); sceneRoot.getChildren().add(ambientLight); updateLabels(); @@ -1938,13 +1936,16 @@ private void wireEventHandlers() { // Lighting scene.addEventHandler(HypersurfaceEvent.AMBIENT_ENABLED_CHANGED, e -> { - // (Optional: enable/disable ambientLight as desired) + 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 -> { - // (Optional: enable/disable pointLight as desired) + 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 -> { Color color = (Color) e.object; From 2034584f882513ad1342eb06185bc1a556ade4e9 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Thu, 8 Oct 2026 10:18:47 -0400 Subject: [PATCH 02/10] centralized surface coordinate conversions. Fixed point light color control Still needs to fix hover controls --- .../panes/HypersurfaceControlsPane.java | 94 +++++++++- .../javafx/javafx3d/Hypersurface3DPane.java | 75 +++++--- .../javafx3d/SurfaceCoordinateMapper.java | 172 ++++++++++++++++++ 3 files changed, 308 insertions(+), 33 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java 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 05a80c98..8fcaab7c 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 @@ -58,6 +58,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; @@ -134,14 +142,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(); }); @@ -194,10 +214,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) -> @@ -669,6 +702,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"); @@ -685,6 +719,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); @@ -729,8 +800,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/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index db977822..7fe7c281 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -151,6 +151,7 @@ public class Hypersurface3DPane extends StackPane 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; private static final double CAMERA_ZOOM_STEP = 50.0; public PerspectiveCamera camera; @@ -333,6 +334,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} /** Current mesh spacing in world units per cell for the active LOD. */ private double currentLodSurfScaleX = Double.NaN; private double currentLodSurfScaleZ = Double.NaN; + private final SurfaceCoordinateMapper coordinateMapper = new SurfaceCoordinateMapper(); /** Screen-space LOD scheduler/selector for the legacy/global renderer. */ private LodManager lodManager = null; @@ -367,6 +369,7 @@ public Hypersurface3DPane(Scene scene) { this.scene = scene; surfaceRowOrientation.addListener((obs, oldValue, newValue) -> { applySurfaceRowOrientationToRenderers(); + refreshCoordinateMapper(); if (!suppressRowOrientationRefresh && surfPlot != null) { updateTheMesh(); } @@ -450,6 +453,9 @@ public Hypersurface3DPane(Scene scene) { 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); subScene.setOnKeyPressed(event -> { KeyCode keycode = event.getCode(); @@ -1440,15 +1446,9 @@ private void handleSurfaceHover(Point3D surfacePoint) { 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()); + int row = coordinateMapper.surfaceZToRenderRow(vertP3D.getZ()); + int column = coordinateMapper.surfaceXToRenderColumn(vertP3D.getX()); + int sourceRow = coordinateMapper.surfaceZToSourceRow(vertP3D.getZ()); if (anchorCallout != null) { if (sourceRow < featureVectors.size()) { @@ -1745,9 +1745,9 @@ private void loadSurf3D() { 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)); + int maxSourceRow = Math.max(0, getSourceHeight() - 1); + anchorIndex = Math.max(0, Math.min(anchorIndex, maxSourceRow)); + glowLineBox.setTranslateZ(coordinateMapper.sourceRowToWorldZ(anchorIndex)); setSpheroidAnchor(true, anchorIndex); eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + getSourceWidth()); westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); @@ -1824,6 +1824,7 @@ private void wireEventHandlers() { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } + refreshCoordinateMapper(); updateTheMesh(); }); @@ -1834,6 +1835,7 @@ private void wireEventHandlers() { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } + refreshCoordinateMapper(); updateTheMesh(); }); @@ -2581,7 +2583,23 @@ private void resetLodDataState() { } } - private int getSourceWidth() { + public boolean isImageBackedSurface() { + return imageBackedSurface; + } + + public int getRenderL0Width() { + return lodProcessedLevels != null && !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0).width() + : getRenderWidth(); + } + + public int getRenderL0Height() { + return lodProcessedLevels != null && !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0).height() + : getRenderHeight(); + } + + public int getSourceWidth() { if (imageBackedSurface && imageSourceWidth > 0) return imageSourceWidth; if (processingSourceHeightField != null) return processingSourceHeightField.width(); if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.get(0).size(); @@ -2589,7 +2607,7 @@ private int getSourceWidth() { return Math.max(1, xWidth); } - private int getSourceHeight() { + public int getSourceHeight() { if (imageBackedSurface && imageSourceHeight > 0) return imageSourceHeight; if (processingSourceHeightField != null) return processingSourceHeightField.height(); if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.size(); @@ -2597,11 +2615,11 @@ private int getSourceHeight() { return Math.max(1, zWidth); } - private int getRenderWidth() { + public int getRenderWidth() { return activeHeightField != null ? activeHeightField.width() : Math.max(1, xWidth); } - private int getRenderHeight() { + public int getRenderHeight() { return activeHeightField != null ? activeHeightField.height() : Math.max(1, zWidth); } @@ -2613,14 +2631,14 @@ private double getRenderScaleZ() { return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; } - private double getWorldWidth() { + public double getWorldWidth() { if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; int sourceWidth = getSourceWidth(); int sourceHeight = getSourceHeight(); return calculateWorldWidth(sourceWidth, sourceHeight); } - private double getWorldDepth() { + public double getWorldDepth() { if (Double.isFinite(baseWorldDepth)) return baseWorldDepth; int sourceWidth = getSourceWidth(); int sourceHeight = getSourceHeight(); @@ -2652,12 +2670,18 @@ private void recomputeBaseWorldExtents() { int sourceHeight = getSourceHeight(); baseWorldWidth = calculateWorldWidth(sourceWidth, sourceHeight); baseWorldDepth = calculateWorldDepth(sourceWidth, sourceHeight); + refreshCoordinateMapper(); } - 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 void refreshCoordinateMapper() { + coordinateMapper.configure( + getSourceWidth(), + getSourceHeight(), + getRenderWidth(), + getRenderHeight(), + getWorldWidth(), + getWorldDepth(), + getSurfaceRowOrientation()); } private double getActiveRenderValue(int row, int column) { @@ -2972,6 +2996,7 @@ private void configureTiledPyramid() { zWidth = activeHeightField.height(); currentLodSurfScaleX = baseWorldWidth / xWidth; currentLodSurfScaleZ = baseWorldDepth / zWidth; + refreshCoordinateMapper(); tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); tiledSurfaceRenderer.setPyramid( @@ -3083,6 +3108,7 @@ private void applyActiveLodIndex(int idx) { currentLodSurfScaleX = baseWorldWidth / xWidth; currentLodSurfScaleZ = baseWorldDepth / zWidth; + refreshCoordinateMapper(); System.out.println("Applying Hypersurface LOD L" + activeLodIndex + ": " + xWidth + "x" + zWidth @@ -3179,7 +3205,10 @@ private void highlightSurfaceRowIfPossible(GraphNode node) { 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); + Point3D center = new Point3D( + coordinateMapper.sourceColumnToSurfaceX(getSourceWidth() / 2), + 0, + coordinateMapper.sourceRowToSurfaceZ(clamped)); illuminateCrosshair(center); }); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java new file mode 100644 index 00000000..f65523b0 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java @@ -0,0 +1,172 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * 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); + } + + 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; + } +} From b4e0ecc92e21c2250651ffb03c394363751c1053 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Thu, 8 Oct 2026 16:40:32 -0400 Subject: [PATCH 03/10] Camera Controller support. --- .../panes/HypersurfaceControlsPane.java | 43 +- .../javafx/javafx3d/Hypersurface3DPane.java | 407 ++++++++--------- .../HypersurfaceCameraController.java | 260 +++++++++++ .../javafx3d/SurfaceCrosshairOverlay.java | 424 ++++++++++++++++++ .../javafx3d/images/SolidColorTexture.java | 35 ++ 5 files changed, 963 insertions(+), 206 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/images/SolidColorTexture.java 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 8fcaab7c..cee8a4ae 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 @@ -6,6 +6,7 @@ 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.HypersurfaceCameraController; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceHeightOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceRowOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; @@ -17,6 +18,7 @@ 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; @@ -367,6 +369,37 @@ private TabPane buildTabs() { 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(145.0); + Button applyCameraButton = new Button("Apply"); + HBox cameraBox = new HBox(6, cameraViewCombo, applyCameraButton); + cameraBox.setAlignment(Pos.CENTER_LEFT); + addRow(sceneGrid, 4, "Camera", cameraBox); + + 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(); showGraphToggle = new ToggleButton("Show Graph"); @@ -379,8 +412,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)); @@ -637,12 +669,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; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 7fe7c281..57af118e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -1,6 +1,7 @@ package edu.jhuapl.trinity.javafx.javafx3d; 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.graph.GraphDirectedCollection; @@ -33,6 +34,7 @@ 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.images.SolidColorTexture; import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.ExMaxClusterTask; @@ -85,7 +87,6 @@ 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; @@ -104,10 +105,8 @@ 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; @@ -129,6 +128,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -156,6 +156,7 @@ public class Hypersurface3DPane extends StackPane public PerspectiveCamera camera; public CameraTransformer cameraTransform = new CameraTransformer(); + private final HypersurfaceCameraController cameraController; public XFormGroup dataXForm = new XFormGroup(); private double cameraDistance = -1000; @@ -209,10 +210,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private String lastImageSource = null; public List shapleyVectors = new ArrayList<>(); - WritableImage diffusePaintImage; - PhongMaterial paintPhong; - TriangleMesh paintTriangleMesh; - MeshView paintMeshView; + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay = new SurfaceCrosshairOverlay(); //allows 2D labels to track their 3D counterparts HashMap shape3DToLabel = new HashMap<>(); @@ -261,7 +259,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private Sphere xSphere = new Sphere(10); private Sphere ySphere = new Sphere(10); private Sphere zSphere = new Sphere(10); - Sphere highlightedPoint = new Sphere(2, 32); + Sphere highlightedPoint = new Sphere(1, 32); private Label xLabel = new Label("Features (ordered)"); private Label yLabel = new Label("Magnitude"); private Label zLabel = new Label("Time (Samples)"); @@ -358,6 +356,9 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private final Group graphLayer = new Group(); // sits in sceneRoot private boolean graphVisible = true; private GraphDirectedCollection currentGraph = null; + private final IdentityHashMap graphNodeRowIndex = new IdentityHashMap<>(); + private final HashMap graphEntityIdToIndex = new HashMap<>(); + private int[][] graphAdjacencyIndices = new int[0][]; private Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() .withNodeRadius(20.0) .withEdgeWidth(8.0f) @@ -376,6 +377,7 @@ public Hypersurface3DPane(Scene scene) { }); surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { applySurfaceHeightOrientationToRenderers(); + refreshCrosshairSurfaceContext(); if (surfPlot != null) { updateTheMesh(); } @@ -398,8 +400,11 @@ public Hypersurface3DPane(Scene scene) { 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); + PhongMaterial highlightedPointMaterial = new PhongMaterial(); + highlightedPointMaterial.setDiffuseColor(null); + highlightedPointMaterial.setSelfIlluminationMap( + SolidColorTexture.of(new Color(0.9411765f, 0.972549f, 1.0f, 0.666))); + highlightedPoint.setMaterial(highlightedPointMaterial); highlightedPoint.setMouseTransparent(true); // Labels @@ -435,6 +440,12 @@ public Hypersurface3DPane(Scene scene) { camera.setTranslateZ(cameraDistance); cameraTransform.ry.setAngle(-45.0); cameraTransform.rx.setAngle(-10.0); + cameraController = new HypersurfaceCameraController( + camera, + cameraTransform, + subScene, + this::getCameraSurfaceBounds, + this::refreshAfterCameraPreset); setupSkyBox(); debugGroup.setVisible(false); extrasGroup.setVisible(false); @@ -443,6 +454,7 @@ public Hypersurface3DPane(Scene scene) { nodeGroup, extrasGroup, debugGroup, dataXForm); // Add graph layer last so it draws above the surface (z-order within Group) sceneRoot.getChildren().add(graphLayer); + sceneRoot.getChildren().add(surfaceCrosshairOverlay); graphLayer.setVisible(graphVisible); // Sync controls with current visibility on startup fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); @@ -765,6 +777,7 @@ public Hypersurface3DPane(Scene scene) { this.scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { if (!(e.object instanceof GraphDirectedCollection gc)) return; currentGraph = gc; + rebuildGraphNodeRowIndex(gc); graphLayer.getChildren().clear(); graphLayer.getChildren().add(Graph3DRenderer.buildGraphGroup(gc, graphParams)); @@ -807,6 +820,8 @@ public Hypersurface3DPane(Scene scene) { 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 optBT = alert.showAndWait(); if (optBT.get().equals(ButtonType.CANCEL)) return; split = optBT.get().equals(ButtonType.YES); @@ -1112,6 +1127,7 @@ public void addCallout(Callout callout, Shape3D shape3D) { public void updateTheMesh() { if (surfPlot == null) return; + surfaceCrosshairOverlay.hide(); sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); final boolean useTiledRenderer = surfaceRender && shouldUseTiledHeightFieldRenderer(); @@ -1120,7 +1136,6 @@ public void updateTheMesh() { } if (useTiledRenderer) { surfPlot.setVisible(false); - if (paintMeshView != null) paintMeshView.setVisible(false); configureTiledAppearance(); tiledSurfaceRenderer.forceUpdate(); return; @@ -1133,12 +1148,10 @@ public void updateTheMesh() { // 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(); @@ -1182,7 +1195,6 @@ public void updateTheMesh() { } Platform.runLater(() -> sceneRoot.getChildren().add(tube)); } - Platform.runLater(this::updatePaintMesh); } /** @@ -1245,109 +1257,6 @@ private void applyCurrentColoration() { } } - 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()); @@ -1371,18 +1280,26 @@ private void notifyIndexChange() { } 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(); - } + 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) { camera.setTranslateZ(DEFAULT_INTRO_DISTANCE); - JavaFX3DUtils.zoomTransition(milliseconds, camera, cameraDistance); + cameraController.fit(milliseconds); } public void outtro(double milliseconds) { @@ -1429,6 +1346,12 @@ private void refreshAfterCameraInteraction(LodManager.UpdateReason reason) { requestLodUpdate(reason); } + private void refreshAfterCameraPreset() { + updateLabels(); + updateCalloutHeadPoints(subScene); + forceLodUpdate(); + } + private void updateLabels() { shape3DToLabel.forEach((shape3D, node) -> { Point2D p2Ditty = JavaFX3DUtils.getTransformedP2D(shape3D, subScene, 5); @@ -1456,9 +1379,8 @@ private void handleSurfaceHover(Point3D surfacePoint) { } setSpheroidAnchor(false, sourceRow); } - if (crosshairsEnabled && diffusePaintImage != null) { - paintSingleColor(Color.TRANSPARENT); - illuminateCrosshair(vertP3D); + if (crosshairsEnabled && activeHeightField != null) { + surfaceCrosshairOverlay.requestRenderPosition(row, column); } if (surfaceChartsEnabled && activeHeightField != null) { List xlist = getActiveRenderRow(row); @@ -1637,8 +1559,21 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, Point3D vertP3D; private void configureLightingScopes() { + // Keep the camera-following point light focused on the analytical surface/graph. pointLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); - ambientLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); + + // 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, + tiledSurfaceRenderer, + graphLayer, + nodeGroup, + extrasGroup, + debugGroup, + highlightedPoint, + surfaceCrosshairOverlay); } private void loadSurf3D() { @@ -1843,6 +1778,7 @@ private void wireEventHandlers() { if (syncingGuiControls) return; this.yScale = ((Double) e.object).floatValue(); if (surfPlot != null) surfPlot.setFunctionScale(yScale); + refreshCrosshairSurfaceContext(); updateTheMesh(); }); @@ -1965,7 +1901,10 @@ private void wireEventHandlers() { }); 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); + scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> { + crosshairsEnabled = (boolean) e.object; + if (!crosshairsEnabled) surfaceCrosshairOverlay.hide(); + }); // Commands/actions scene.addEventHandler(HypersurfaceEvent.RESET_VIEW, e -> resetView(1000, false)); @@ -1979,27 +1918,27 @@ private void wireEventHandlers() { 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 - )); + // Crosshair movement must remain cheap. Only build/publish the dense + // graph-analysis vector when the charting feature is explicitly enabled. + if (surfaceChartsEnabled) { + Double[] row = buildAdjacencyRowFromGraph(gNode); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, + "Graph Adjacency 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); + Double[] row = buildAdjacencyRowFromGraph(gNode); scene.getRoot().fireEvent(new FactorAnalysisEvent( FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Similarity Row (click): " + gNode, + "Graph Adjacency Row (click): " + gNode, row )); highlightSurfaceRowIfPossible(gNode); @@ -2514,9 +2453,7 @@ private void releaseImageBackedStateForReplacement() { if (surfPlot != null) { surfPlot.setVisible(false); } - if (paintMeshView != null) { - paintMeshView.setVisible(false); - } + surfaceCrosshairOverlay.hide(); processingSourceHeightField = null; imageSourceStartX = 0; @@ -2631,6 +2568,28 @@ private double getRenderScaleZ() { return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; } + private HypersurfaceCameraController.SurfaceBounds getCameraSurfaceBounds() { + double maxAbsY = 0.0; + if (activeHeightField != null) { + float[] minMax = activeHeightField.minMax(); + maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + } else if (processingSourceHeightField != null) { + float[] minMax = processingSourceHeightField.minMax(); + maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + } else if (surfPlot != null) { + maxAbsY = Math.abs(surfPlot.getMaxAbsY()); + } + + // Height is deliberately symmetric around Y=0. Hypersurface scalar values are + // often one-sided, so using 2*maxAbsY guarantees the fit sphere remains centered + // on the same origin used by camera rotation and panning. + double worldHeight = Math.max(1.0, maxAbsY * 2.0); + return new HypersurfaceCameraController.SurfaceBounds( + getWorldWidth(), + worldHeight, + getWorldDepth()); + } + public double getWorldWidth() { if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; int sourceWidth = getSourceWidth(); @@ -2682,6 +2641,16 @@ private void refreshCoordinateMapper() { getWorldWidth(), getWorldDepth(), getSurfaceRowOrientation()); + refreshCrosshairSurfaceContext(); + } + + private void refreshCrosshairSurfaceContext() { + surfaceCrosshairOverlay.configureSurface( + activeHeightField, + coordinateMapper, + yScale, + getSurfaceRowOrientation(), + getSurfaceHeightOrientation()); } private double getActiveRenderValue(int row, int column) { @@ -3124,43 +3093,25 @@ private void applyActiveLodIndex(int idx) { } /** - * Build a dense similarity/divergence row for a node from the current sparse graph. + * Build a dense binary adjacency row from the cached sparse graph topology. + * GraphEdge does not carry a numeric weight, so a connected neighbor is 1.0 + * and a non-neighbor is 0.0. */ - private Double[] buildSimilarityRowFromGraph(GraphNode node) { + private Double[] buildAdjacencyRowFromGraph(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; - } - }); - } + 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]; - for (int i = 0; i < n; i++) out[i] = row[i]; + Arrays.fill(out, 0.0); + for (int neighbor : graphAdjacencyIndices[nodeIndex]) { + if (neighbor >= 0 && neighbor < n) out[neighbor] = 1.0; + } return out; } @@ -3177,39 +3128,89 @@ private double getEdgeWeightSafe(GraphEdge e) { } /** - * Optional: if a GraphNode encodes a source row index for the hypersurface, try to extract it. + * Rebuild graph interaction caches once when a new graph arrives. + * + *

For source-row alignment, prefer GraphNode.identity only when every node + * has a unique identity inside the current source-row range. Otherwise, when + * graph node count exactly matches source height, collection order defines the + * row association. If neither condition is true, no row mapping is invented.

*/ - 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) { + private void rebuildGraphNodeRowIndex(GraphDirectedCollection 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 = 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; + } } - // (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) { + + 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); + } } - return Optional.empty(); + + 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 Optional getGraphSourceRowIndex(GraphNode node) { + if (node == null || currentGraph == null) return Optional.empty(); + Integer row = graphNodeRowIndex.get(node); + if (row == null || row < 0 || row >= getSourceHeight()) return Optional.empty(); + return Optional.of(row); } /** - * Highlight a row on the hypersurface if we can map a node to a data row. + * Highlight the graph node's corresponding surface row. The selected graph row + * spans X and the perpendicular crosshair uses the center surface column. */ 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( - coordinateMapper.sourceColumnToSurfaceX(getSourceWidth() / 2), - 0, - coordinateMapper.sourceRowToSurfaceZ(clamped)); - illuminateCrosshair(center); - }); + if (!crosshairsEnabled || activeHeightField == null) { + surfaceCrosshairOverlay.hide(); + return; + } + getGraphSourceRowIndex(node).ifPresent(surfaceCrosshairOverlay::requestSourceRow); } + } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java new file mode 100644 index 00000000..a284126d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java @@ -0,0 +1,260 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import edu.jhuapl.trinity.utils.JavaFX3DUtils; +import javafx.animation.Timeline; +import javafx.geometry.Point3D; +import javafx.scene.PerspectiveCamera; +import javafx.scene.SubScene; +import org.fxyz3d.utils.CameraTransformer; + +/** + * Owns camera 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.

+ */ +public final class HypersurfaceCameraController { + + 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 final PerspectiveCamera camera; + private final CameraTransformer cameraTransform; + private final SubScene subScene; + private final BoundsProvider boundsProvider; + private final Runnable cameraSettledCallback; + + private Timeline activeTransition; + private double fitPadding = DEFAULT_FIT_PADDING; + + public HypersurfaceCameraController(PerspectiveCamera camera, + CameraTransformer cameraTransform, + SubScene subScene, + BoundsProvider boundsProvider, + 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.cameraSettledCallback = cameraSettledCallback != null + ? cameraSettledCallback + : () -> { }; + } + + /** + * 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; + } + + /** + * 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/SurfaceCrosshairOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java new file mode 100644 index 00000000..1c6b1a6d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java @@ -0,0 +1,424 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +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/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 From 70eef9bf8841e96b6a56bde81f6ea7ded0d6f097 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Fri, 9 Oct 2026 08:11:15 -0400 Subject: [PATCH 04/10] Legend and Camera upgrades for Hypersurface3DPane --- .../edu/jhuapl/trinity/AppAsyncManager.java | 10 +- .../panes/HypersurfaceControlsPane.java | 21 ++- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 12 +- .../javafx/javafx3d/Hypersurface3DPane.java | 136 +++++++++++++++- .../javafx3d/HypersurfaceLegendOverlay.java | 146 ++++++++++++++++++ .../javafx3d/HypersurfaceLegendState.java | 18 +++ .../javafx/javafx3d/SurfaceColorPalette.java | 34 ++++ 7 files changed, 359 insertions(+), 18 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceColorPalette.java diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index ab19bb95..240eae40 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -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/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index cee8a4ae..7b6aa424 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 @@ -95,6 +95,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private ColorPicker ambientColorPicker; private CheckBox enablePointCheck; private ColorPicker pointColorPicker; + private CheckBox showLegendCheck; // Graph overlay visibility private ToggleButton showGraphToggle; @@ -180,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) { @@ -372,12 +381,20 @@ private TabPane buildTabs() { ComboBox cameraViewCombo = new ComboBox<>(); cameraViewCombo.getItems().addAll("Fit", "Oblique", "Top", "Front", "Side"); cameraViewCombo.getSelectionModel().select("Fit"); - cameraViewCombo.setPrefWidth(145.0); + cameraViewCombo.setPrefWidth(125.0); Button applyCameraButton = new Button("Apply"); - HBox cameraBox = new HBox(6, cameraViewCombo, applyCameraButton); + 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(); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java index 913092ba..cbd153cd 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -40,7 +40,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; @@ -708,16 +707,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/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 57af118e..9ea0b49b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -58,7 +58,10 @@ 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.beans.property.SimpleObjectProperty; import javafx.embed.swing.SwingFXUtils; import javafx.event.ActionEvent; @@ -211,6 +214,12 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public List shapleyVectors = new ArrayList<>(); private final SurfaceCrosshairOverlay surfaceCrosshairOverlay = new SurfaceCrosshairOverlay(); + /** + * 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); //allows 2D labels to track their 3D counterparts HashMap shape3DToLabel = new HashMap<>(); @@ -368,6 +377,13 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public Hypersurface3DPane(Scene scene) { this.scene = scene; + 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()); + }); surfaceRowOrientation.addListener((obs, oldValue, newValue) -> { applySurfaceRowOrientationToRenderers(); refreshCoordinateMapper(); @@ -378,6 +394,7 @@ public Hypersurface3DPane(Scene scene) { surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { applySurfaceHeightOrientationToRenderers(); refreshCrosshairSurfaceContext(); + refreshLegendOverlay(); if (surfPlot != null) { updateTheMesh(); } @@ -400,11 +417,12 @@ public Hypersurface3DPane(Scene scene) { ySphere.setMaterial(new PhongMaterial(Color.GREEN)); zSphere.setTranslateZ(planeSize / 2.0); zSphere.setMaterial(new PhongMaterial(Color.BLUE)); - PhongMaterial highlightedPointMaterial = new PhongMaterial(); - highlightedPointMaterial.setDiffuseColor(null); + PhongMaterial highlightedPointMaterial = + new PhongMaterial(null, null, null, null, null); highlightedPointMaterial.setSelfIlluminationMap( - SolidColorTexture.of(new Color(0.9411765f, 0.972549f, 1.0f, 0.666))); + SolidColorTexture.of(Color.ALICEBLUE.deriveColor(1, 1, 1, 0.666))); highlightedPoint.setMaterial(highlightedPointMaterial); + highlightedPoint.setDrawMode(DrawMode.FILL); highlightedPoint.setMouseTransparent(true); // Labels @@ -1779,6 +1797,7 @@ private void wireEventHandlers() { this.yScale = ((Double) e.object).floatValue(); if (surfPlot != null) surfPlot.setFunctionScale(yScale); refreshCrosshairSurfaceContext(); + refreshLegendOverlay(); updateTheMesh(); }); @@ -1832,6 +1851,7 @@ private void wireEventHandlers() { } else { applyCurrentColoration(); } + refreshLegendOverlay(); }); // Processing pipeline @@ -1976,6 +1996,114 @@ private void wireEventHandlers() { }); } + 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 = lodProcessedLevels != null && !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0) + : activeHeightField; + + double minimum = Double.NaN; + double maximum = Double.NaN; + if (rangeField != null) { + float[] minMax = rangeField.minMax(); + minimum = minMax[0]; + maximum = minMax[1]; + } + + String heightLabel = imageBackedSurface ? "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 updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { callout.setMainTitleText(featureVector.getLabel()); callout.mainTitleTextNode.setText(callout.getMainTitleText()); @@ -2518,6 +2646,7 @@ private void resetLodDataState() { tiledSurfaceRenderer.clearSurface(); tiledSurfaceRenderer.setVisible(false); } + refreshLegendOverlay(); } public boolean isImageBackedSurface() { @@ -3053,6 +3182,7 @@ private void rebuildProcessedGridAndRefresh() { } updateView(true); + refreshLegendOverlay(); } /** diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java new file mode 100644 index 00000000..bd67b1af --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java @@ -0,0 +1,146 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +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/HypersurfaceLegendState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java new file mode 100644 index 00000000..bf4e8057 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java @@ -0,0 +1,18 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * 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/SurfaceColorPalette.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceColorPalette.java new file mode 100644 index 00000000..016805b3 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceColorPalette.java @@ -0,0 +1,34 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +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; + } +} From 1bbaed445901ee8c4b1839448ebb7b198de9e96f Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Fri, 9 Oct 2026 15:16:47 -0400 Subject: [PATCH 05/10] Refactoring of hypersurface files into dedicated package. --- .../edu/jhuapl/trinity/AppAsyncManager.java | 2 +- .../panes/HypersurfaceControlsPane.java | 14 +- .../javafx/javafx3d/RetroWavePane.java | 1 + .../javafx3d/animated/TessellationMesh.java | 2 +- .../{ => hypersurface}/HeightField.java | 2 +- .../HyperSurfacePlotMesh.java | 6 +- .../Hypersurface3DPane.java | 350 +++------------ .../HypersurfaceCameraController.java | 2 +- .../HypersurfaceInteractionController.java | 404 ++++++++++++++++++ .../HypersurfaceLegendOverlay.java | 2 +- .../HypersurfaceLegendState.java | 2 +- .../{ => hypersurface}/LodManager.java | 2 +- .../{ => hypersurface}/LodTransition.java | 2 +- .../SurfaceColorPalette.java | 2 +- .../SurfaceCoordinateMapper.java | 2 +- .../SurfaceCrosshairOverlay.java | 2 +- .../SurfaceHeightOrientation.java | 2 +- .../SurfaceRowOrientation.java | 2 +- .../{ => hypersurface}/SurfaceUtils.java | 2 +- .../{ => hypersurface}/TileRenderState.java | 2 +- .../{ => hypersurface}/TiledLodManager.java | 2 +- .../TiledSurfaceRenderer.java | 2 +- .../javafx/javafx3d/SurfaceWarpTest.java | 1 + .../javafx/javafx3d/VectorizeImageTest.java | 1 + 24 files changed, 504 insertions(+), 307 deletions(-) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/HeightField.java (98%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/HyperSurfacePlotMesh.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/Hypersurface3DPane.java (90%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/HypersurfaceCameraController.java (99%) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/HypersurfaceLegendOverlay.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/HypersurfaceLegendState.java (88%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/LodManager.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/LodTransition.java (95%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceColorPalette.java (94%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceCoordinateMapper.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceCrosshairOverlay.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceHeightOrientation.java (96%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceRowOrientation.java (94%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/SurfaceUtils.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/TileRenderState.java (97%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/TiledLodManager.java (99%) rename src/main/java/edu/jhuapl/trinity/javafx/javafx3d/{ => hypersurface}/TiledSurfaceRenderer.java (99%) diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index 240eae40..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; 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 7b6aa424..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,13 +5,13 @@ 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.HypersurfaceCameraController; -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; 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 99% 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 cbd153cd..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; @@ -358,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(), diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java similarity index 90% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java index 9ea0b49b..108fa01d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -1,12 +1,10 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +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.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; @@ -30,6 +28,8 @@ 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.Vert3D; +import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; import edu.jhuapl.trinity.javafx.javafx3d.animated.Tracer; @@ -128,10 +128,8 @@ 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.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -204,9 +202,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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; @@ -214,6 +209,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public List shapleyVectors = new ArrayList<>(); private final SurfaceCrosshairOverlay surfaceCrosshairOverlay = new SurfaceCrosshairOverlay(); + private final HypersurfaceInteractionController interactionController; /** * Desktop-level legend HUD. Constructed lazily on the JavaFX Application Thread * because Hypersurface3DPane itself is created by AppAsyncManager on a worker thread. @@ -365,9 +361,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private final Group graphLayer = new Group(); // sits in sceneRoot private boolean graphVisible = true; private GraphDirectedCollection currentGraph = null; - private final IdentityHashMap graphNodeRowIndex = new IdentityHashMap<>(); - private final HashMap graphEntityIdToIndex = new HashMap<>(); - private int[][] graphAdjacencyIndices = new int[0][]; private Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() .withNodeRadius(20.0) .withEdgeWidth(8.0f) @@ -377,6 +370,8 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public Hypersurface3DPane(Scene scene) { this.scene = scene; + interactionController = new HypersurfaceInteractionController( + this, scene, coordinateMapper, surfaceCrosshairOverlay); 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 @@ -689,30 +684,16 @@ public Hypersurface3DPane(Scene scene) { ); CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); - enableHoverItem.setOnAction(e -> { - hoverInteractionsEnabled = enableHoverItem.isSelected(); - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); - } - }); + enableHoverItem.setOnAction(e -> + interactionController.setHoverEnabled(enableHoverItem.isSelected())); 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(); - } - } - }); + surfaceChartsItem.setOnAction(e -> + interactionController.setSurfaceChartsEnabled(surfaceChartsItem.isSelected())); + + MenuItem navigatorItem = new MenuItem("Content Navigator"); + navigatorItem.setOnAction(e -> interactionController.showContentNavigator( + imageBackedSurface ? lastImage : null)); MenuItem updateAllItem = new MenuItem("Update Render"); updateAllItem.setOnAction(e -> updateAll()); @@ -735,11 +716,12 @@ public Hypersurface3DPane(Scene scene) { }); CheckMenuItem enableCrosshairsItem = new CheckMenuItem("Enable Crosshairs"); - enableCrosshairsItem.setOnAction(e -> crosshairsEnabled = enableCrosshairsItem.isSelected()); + enableCrosshairsItem.setOnAction(e -> + interactionController.setCrosshairsEnabled(enableCrosshairsItem.isSelected())); MenuItem resetViewItem = new MenuItem("Reset View"); resetViewItem.setOnAction(e -> resetView(1000, false)); - ContextMenu cm = new ContextMenu(showControlsItem, + ContextMenu cm = new ContextMenu(showControlsItem, navigatorItem, copyAsImageItem, saveSnapshotItem, unrollHyperspaceItem, analysisMenu, enableHoverItem, surfaceChartsItem, showDataMarkersItem, enableCrosshairsItem, updateAllItem, clearDataItem, resetViewItem); @@ -795,7 +777,7 @@ public Hypersurface3DPane(Scene scene) { this.scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { if (!(e.object instanceof GraphDirectedCollection gc)) return; currentGraph = gc; - rebuildGraphNodeRowIndex(gc); + interactionController.setGraph(gc); graphLayer.getChildren().clear(); graphLayer.getChildren().add(Graph3DRenderer.buildGraphGroup(gc, graphParams)); @@ -1379,51 +1361,49 @@ private void updateLabels() { }); } - private void handleSurfaceHover(Point3D surfacePoint) { + void updateInteractionHoverMarker(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 = coordinateMapper.surfaceZToRenderRow(vertP3D.getZ()); - int column = coordinateMapper.surfaceXToRenderColumn(vertP3D.getX()); - int sourceRow = coordinateMapper.surfaceZToSourceRow(vertP3D.getZ()); + void updateInteractionHoverText(String text) { + hoverText.setText(text); + hoverText.setStrokeWidth(1); + hoverText.setLayoutX(50); + hoverText.setLayoutY(50); + } - if (anchorCallout != null) { - if (sourceRow < featureVectors.size()) { - updateCalloutByFeatureVector(anchorCallout, featureVectors.get(sourceRow)); - } - setSpheroidAnchor(false, sourceRow); - } - if (crosshairsEnabled && activeHeightField != null) { - surfaceCrosshairOverlay.requestRenderPosition(row, column); - } - 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)); + HeightField getActiveHeightFieldForInteraction() { + return activeHeightField; + } + + List getFeatureVectorsForInteraction() { + return featureVectors; + } + + List getFeatureLabelsForInteraction() { + return featureLabels; + } + + TiledSurfaceRenderer getTiledSurfaceRendererForInteraction() { + return tiledSurfaceRenderer; + } + + 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(); } } @@ -1630,32 +1610,15 @@ private void loadSurf3D() { 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(); - }); } + interactionController.installSurfacePicking(surfPlot, tiledSurfaceRenderer); + // 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; @@ -1913,18 +1876,13 @@ private void wireEventHandlers() { }); // 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.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 -> extrasGroup.setVisible((boolean) e.object)); - scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> { - crosshairsEnabled = (boolean) e.object; - if (!crosshairsEnabled) surfaceCrosshairOverlay.hide(); - }); + scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> + interactionController.setCrosshairsEnabled((boolean) e.object)); // Commands/actions scene.addEventHandler(HypersurfaceEvent.RESET_VIEW, e -> resetView(1000, false)); @@ -1934,61 +1892,6 @@ private void wireEventHandlers() { 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; - - // Crosshair movement must remain cheap. Only build/publish the dense - // graph-analysis vector when the charting feature is explicitly enabled. - if (surfaceChartsEnabled) { - Double[] row = buildAdjacencyRowFromGraph(gNode); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Adjacency Row (hover): " + gNode, - 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 = buildAdjacencyRowFromGraph(gNode); - scene.getRoot().fireEvent(new FactorAnalysisEvent( - FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, - "Graph Adjacency 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; @@ -2151,6 +2054,7 @@ public void clearAll() { dataGrid.clear(); featureVectors.clear(); originalGrid.clear(); + interactionController.resetSelectionState(); resetLodDataState(); } @@ -2184,6 +2088,7 @@ public void showFA3D() { @Override public void setFeatureCollection(FeatureCollection fc) { featureVectors = fc.getFeatures(); + interactionController.resetSelectionState(); } public void findClusters(ManifoldEvent.ProjectionConfig pc) { @@ -2319,6 +2224,7 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl rebuildProcessedGridAndRefresh(); getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); featureVectors = featureCollection.getFeatures(); + interactionController.resetSelectionState(); } @Override @@ -2339,6 +2245,7 @@ public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); originalGrid.clear(); + interactionController.resetSelectionState(); resetLodDataState(); } @@ -2489,6 +2396,7 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { // 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(); + interactionController.resetSelectionState(); if (dataGrid == null) dataGrid = new ArrayList<>(); else dataGrid.clear(); if (originalGrid == null) originalGrid = new ArrayList<>(); @@ -2782,14 +2690,14 @@ private void refreshCrosshairSurfaceContext() { getSurfaceHeightOrientation()); } - private double getActiveRenderValue(int row, int column) { + 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) { + List getActiveRenderRow(int row) { if (activeHeightField == null) return List.of(); int r = orientRowIndex(row, activeHeightField.height()); int w = activeHeightField.width(); @@ -2800,7 +2708,7 @@ private List getActiveRenderRow(int row) { return out; } - private Double[] getActiveRenderColumn(int column) { + 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(); @@ -3222,125 +3130,5 @@ private void applyActiveLodIndex(int idx) { updateTheMesh(); } - /** - * Build a dense binary adjacency row from the cached sparse graph topology. - * GraphEdge does not carry a numeric weight, so a connected neighbor is 1.0 - * and a non-neighbor is 0.0. - */ - 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; - } - - /** - * 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 - } - } - - /** - * Rebuild graph interaction caches once when a new graph arrives. - * - *

For source-row alignment, prefer GraphNode.identity only when every node - * has a unique identity inside the current source-row range. Otherwise, when - * graph node count exactly matches source height, collection order defines the - * row association. If neither condition is true, no row mapping is invented.

- */ - private void rebuildGraphNodeRowIndex(GraphDirectedCollection 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 = 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 Optional getGraphSourceRowIndex(GraphNode node) { - if (node == null || currentGraph == null) return Optional.empty(); - Integer row = graphNodeRowIndex.get(node); - if (row == null || row < 0 || row >= getSourceHeight()) return Optional.empty(); - return Optional.of(row); - } - - /** - * Highlight the graph node's corresponding surface row. The selected graph row - * spans X and the perpendicular crosshair uses the center surface column. - */ - private void highlightSurfaceRowIfPossible(GraphNode node) { - if (!crosshairsEnabled || activeHeightField == null) { - surfaceCrosshairOverlay.hide(); - return; - } - getGraphSourceRowIndex(node).ifPresent(surfaceCrosshairOverlay::requestSourceRow); - } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java index a284126d..95031499 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceCameraController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import edu.jhuapl.trinity.utils.JavaFX3DUtils; import javafx.animation.Timeline; 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..9c8d3003 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -0,0 +1,404 @@ +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 javafx.scene.Node; +import javafx.scene.Scene; +import javafx.scene.image.Image; +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 boolean hoverEnabled; + private boolean surfaceChartsEnabled; + private boolean crosshairsEnabled; + + private FeatureVector lastPublishedFeatureVector; + private SurfaceInteractionContext lastInteractionContext; + + 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) { + this.pane = pane; + this.scene = scene; + this.coordinateMapper = coordinateMapper; + this.surfaceCrosshairOverlay = surfaceCrosshairOverlay; + 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); + } + } + + public SurfaceInteractionContext getLastInteractionContext() { + return lastInteractionContext; + } + + public void resetSelectionState() { + lastPublishedFeatureVector = null; + lastInteractionContext = null; + surfaceCrosshairOverlay.hide(); + } + + /** + * 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) { + pane.updateInteractionHoverMarker(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 context = new SurfaceInteractionContext( + surfacePoint, renderRow, renderColumn, sourceRow, sourceColumn, value); + lastInteractionContext = context; + + publishFeatureSelection(sourceRow); + + if (crosshairsEnabled && activeHeightField != null) { + surfaceCrosshairOverlay.requestRenderPosition(renderRow, renderColumn); + } + + if (surfaceChartsEnabled && activeHeightField != null) { + 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; + pane.setSpheroidAnchor(false, sourceRow); + 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 void publishSurfaceChartData(SurfaceInteractionContext context) { + List xList = pane.getActiveRenderRow(context.renderRow()); + Double[] xRay = xList.toArray(Double[]::new); + Double[] zRay = pane.getActiveRenderColumn(context.renderColumn()); + + StringBuilder text = new StringBuilder(); + text.append("Coordinates: ") + .append(context.renderColumn()).append(", ").append(context.renderRow()) + .append(System.lineSeparator()); + text.append("Value: ").append(context.value()).append(System.lineSeparator()); + text.append("Max X: ").append(xList.stream().max(Double::compare).orElse(0.0)) + .append(System.lineSeparator()); + text.append("Min X: ").append(xList.stream().min(Double::compare).orElse(0.0)) + .append(System.lineSeparator()); + text.append("Max Z: ").append(Arrays.stream(zRay).max(Double::compare).orElse(0.0)) + .append(System.lineSeparator()); + text.append("Min Z: ").append(Arrays.stream(zRay).min(Double::compare).orElse(0.0)) + .append(System.lineSeparator()); + + pane.updateInteractionHoverText(text.toString()); + 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/HypersurfaceLegendOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java index bd67b1af..d334773b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendOverlay.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendOverlay.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.geometry.Insets; import javafx.geometry.Pos; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java similarity index 88% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java index bf4e8057..de4c2e61 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HypersurfaceLegendState.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceLegendState.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Immutable presentation state for the Hypersurface value/color legend. 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/SurfaceColorPalette.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java similarity index 94% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceColorPalette.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java index 016805b3..01a9528d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceColorPalette.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceColorPalette.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java index f65523b0..2350af57 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCoordinateMapper.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Centralizes coordinate conversion between source data, the active render grid, diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java index 1c6b1a6d..188fbca5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceCrosshairOverlay.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.animation.AnimationTimer; import javafx.scene.Group; 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/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 99% 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..4b68cba4 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; 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; From 072c61285f9f4303b9a56ac3f0ed8458fe18af0b Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Fri, 9 Oct 2026 15:56:49 -0400 Subject: [PATCH 06/10] refactored camera controls and logic out of Hypersurface3DPane --- .../components/panes/NavigatorPane.java | 266 +++++++++++++++++- .../javafx/events/HypersurfaceEvent.java | 12 + .../hypersurface/Hypersurface3DPane.java | 155 +++------- .../HypersurfaceCameraController.java | 225 ++++++++++++++- .../HypersurfaceInteractionController.java | 209 ++++++++++++-- .../hypersurface/SurfaceCoordinateMapper.java | 24 ++ .../hypersurface/SurfaceInspection.java | 52 ++++ .../hypersurface/TiledSurfaceRenderer.java | 43 +++ 8 files changed, 844 insertions(+), 142 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java 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/events/HypersurfaceEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java index 2e814a57..8d8865ce 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/HypersurfaceEvent.java @@ -95,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 = @@ -241,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/hypersurface/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java index 108fa01d..a051fc48 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -96,7 +96,6 @@ 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; @@ -153,7 +152,6 @@ public class Hypersurface3DPane extends StackPane 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; - private static final double CAMERA_ZOOM_STEP = 50.0; public PerspectiveCamera camera; public CameraTransformer cameraTransform = new CameraTransformer(); @@ -165,12 +163,6 @@ public class Hypersurface3DPane extends StackPane 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(); @@ -268,7 +260,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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; @@ -432,18 +423,11 @@ public Hypersurface3DPane(Scene scene) { 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.getChildren().addAll(xLabel, yLabel, zLabel); 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); @@ -458,7 +442,9 @@ public Hypersurface3DPane(Scene scene) { cameraTransform, subScene, this::getCameraSurfaceBounds, - this::refreshAfterCameraPreset); + this::handleCameraInteraction, + this::handleCameraSettled); + cameraController.installInputHandlers(); setupSkyBox(); debugGroup.setVisible(false); extrasGroup.setVisible(false); @@ -484,37 +470,7 @@ public Hypersurface3DPane(Scene scene) { 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); + boolean cameraHandled = cameraController.handleKeyPressed(event); if (keycode == KeyCode.COMMA) { if (xFactorIndex > 0 && yFactorIndex > 0 && zFactorIndex > 0) { @@ -567,34 +523,13 @@ public Hypersurface3DPane(Scene scene) { 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(event -> { - double deltaZ = event.getZoomFactor() > 1.0 - ? CAMERA_ZOOM_STEP - : -CAMERA_ZOOM_STEP; - zoomCamera(deltaZ); - event.consume(); - }); - subScene.setOnScroll((ScrollEvent event) -> { - double modifier = 50.0; - double modifierFactor = 0.1; - if (event.isControlDown()) modifier = 1.0; - if (event.isShiftDown()) modifier = 100.0; - zoomCamera(event.getDeltaY() * modifierFactor * modifier); + if (!cameraHandled) { + updateLabels(); + updateCalloutHeadPoints(subScene); + requestLodUpdate(LodManager.UpdateReason.OTHER); + } }); - subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); Pane pathPane = App.getAppPathPaneStack(); surfaceChartPane = new SurfaceChartPane(scene, pathPane); bp = new BorderPane(subScene); @@ -609,9 +544,6 @@ public Hypersurface3DPane(Scene scene) { }); - subScene.setOnMouseReleased((MouseEvent me) -> { - forceLodUpdate(); - }); MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); copyAsImageItem.setOnAction((ActionEvent e) -> { Clipboard clipboard = Clipboard.getSystemClipboard(); @@ -1298,55 +1230,29 @@ public void applyCameraPreset(HypersurfaceCameraController.Preset preset, double } public void intro(double milliseconds) { - camera.setTranslateZ(DEFAULT_INTRO_DISTANCE); - cameraController.fit(milliseconds); + cameraController.intro(milliseconds, DEFAULT_INTRO_DISTANCE); } public void outtro(double milliseconds) { - JavaFX3DUtils.zoomTransition(milliseconds, camera, DEFAULT_INTRO_DISTANCE); + cameraController.outtro(milliseconds, 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); - } - refreshAfterCameraInteraction(LodManager.UpdateReason.DRAG); - } - - private void zoomCamera(double deltaZ) { - camera.setTranslateZ(camera.getTranslateZ() + deltaZ); - refreshAfterCameraInteraction(LodManager.UpdateReason.SCROLL); - } - - private void refreshAfterCameraInteraction(LodManager.UpdateReason reason) { + private void handleCameraInteraction(HypersurfaceCameraController.InteractionType interactionType) { updateLabels(); updateCalloutHeadPoints(subScene); + LodManager.UpdateReason reason = switch (interactionType) { + case DRAG -> LodManager.UpdateReason.DRAG; + case ZOOM -> LodManager.UpdateReason.SCROLL; + case KEYBOARD -> LodManager.UpdateReason.OTHER; + }; requestLodUpdate(reason); } - private void refreshAfterCameraPreset() { + private void handleCameraSettled() { updateLabels(); updateCalloutHeadPoints(subScene); forceLodUpdate(); @@ -1370,13 +1276,6 @@ void updateInteractionHoverMarker(Point3D surfacePoint) { updateLabels(); } - void updateInteractionHoverText(String text) { - hoverText.setText(text); - hoverText.setStrokeWidth(1); - hoverText.setLayoutX(50); - hoverText.setLayoutY(50); - } - HeightField getActiveHeightFieldForInteraction() { return activeHeightField; } @@ -1393,6 +1292,18 @@ TiledSurfaceRenderer getTiledSurfaceRendererForInteraction() { return tiledSurfaceRenderer; } + Image getSourceImageForInteraction() { + return lastImage; + } + + int getImageSourceStartXForInteraction() { + return imageSourceStartX; + } + + int getImageSourceStartYForInteraction() { + return imageSourceStartY; + } + void showSurfaceChartsPane() { Pane pathPane = App.getAppPathPaneStack(); if (surfaceChartPane == null) { @@ -2050,7 +1961,6 @@ public void clearAll() { shape3DToLabel.put(xSphere, xLabel); shape3DToLabel.put(ySphere, yLabel); shape3DToLabel.put(zSphere, zLabel); - shape3DToLabel.put(highlightedPoint, hoverText); dataGrid.clear(); featureVectors.clear(); originalGrid.clear(); @@ -2076,7 +1986,7 @@ public void hideFA3D() { 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), 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)), @@ -2423,6 +2333,7 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { rebuildProcessedGridAndRefresh(); xSphere.setTranslateX(getWorldWidth() / 2.0); zSphere.setTranslateZ(getWorldDepth() / 2.0); + interactionController.publishSourceImageChanged(image); Utils.printTotalTime(startTime); } 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 index 95031499..986dff0f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceCameraController.java @@ -5,17 +5,36 @@ 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 framing and canonical view presets for the Hypersurface projection. + * 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.

+ * 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), @@ -68,20 +87,29 @@ public interface BoundsProvider { 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"); @@ -92,11 +120,131 @@ public HypersurfaceCameraController(PerspectiveCamera 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. */ @@ -144,6 +292,79 @@ public void setFitPadding(double fitPadding) { 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 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 index 9c8d3003..69f0099a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -9,9 +9,12 @@ 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; @@ -63,6 +66,14 @@ public record SurfaceInteractionContext( 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<>(); @@ -160,6 +171,14 @@ public void showContentNavigator(Image sourceImage) { 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() { @@ -169,7 +188,15 @@ public SurfaceInteractionContext getLastInteractionContext() { 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)); } /** @@ -237,6 +264,8 @@ public void setGraph(GraphDirectedCollection graph) { } 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. pane.updateInteractionHoverMarker(surfacePoint); int renderRow = coordinateMapper.surfaceZToRenderRow(surfacePoint.getZ()); @@ -248,6 +277,7 @@ private void handleSurfaceHover(Point3D surfacePoint) { ? pane.getActiveRenderValue(renderRow, renderColumn) : Double.NaN; + SurfaceInteractionContext previous = lastInteractionContext; SurfaceInteractionContext context = new SurfaceInteractionContext( surfacePoint, renderRow, renderColumn, sourceRow, sourceColumn, value); lastInteractionContext = context; @@ -258,7 +288,19 @@ private void handleSurfaceHover(Point3D surfacePoint) { surfaceCrosshairOverlay.requestRenderPosition(renderRow, renderColumn); } - if (surfaceChartsEnabled && activeHeightField != null) { + 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); } } @@ -288,26 +330,161 @@ private void publishFeatureVector(FeatureVector featureVector, boolean force) { 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()); - StringBuilder text = new StringBuilder(); - text.append("Coordinates: ") - .append(context.renderColumn()).append(", ").append(context.renderRow()) - .append(System.lineSeparator()); - text.append("Value: ").append(context.value()).append(System.lineSeparator()); - text.append("Max X: ").append(xList.stream().max(Double::compare).orElse(0.0)) - .append(System.lineSeparator()); - text.append("Min X: ").append(xList.stream().min(Double::compare).orElse(0.0)) - .append(System.lineSeparator()); - text.append("Max Z: ").append(Arrays.stream(zRay).max(Double::compare).orElse(0.0)) - .append(System.lineSeparator()); - text.append("Min Z: ").append(Arrays.stream(zRay).min(Double::compare).orElse(0.0)) - .append(System.lineSeparator()); - - pane.updateInteractionHoverText(text.toString()); scene.getRoot().fireEvent(new FactorAnalysisEvent( FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); scene.getRoot().fireEvent(new FactorAnalysisEvent( 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 index 2350af57..373e2310 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCoordinateMapper.java @@ -104,6 +104,30 @@ public int renderRowToSourceRow(int renderRow) { 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; } 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/hypersurface/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java index 4b68cba4..3b1d8ac1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java @@ -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; From 8e90e2a2693b26c696311d1f7dc16ea34e3c2852 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Fri, 9 Oct 2026 20:40:25 -0400 Subject: [PATCH 07/10] migrated overlay model and interaction controls out of Hypersurface3DPane --- .../hypersurface/Hypersurface3DPane.java | 902 +++++------------- .../HypersurfaceInteractionController.java | 7 +- .../HypersurfaceOverlayManager.java | 425 +++++++++ .../hypersurface/HypersurfaceSourceModel.java | 427 +++++++++ 4 files changed, 1080 insertions(+), 681 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceSourceModel.java 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 index a051fc48..48e60bc9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -14,7 +14,6 @@ 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; @@ -27,14 +26,12 @@ 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.Vert3D; import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; 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.images.SolidColorTexture; import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.ExMaxClusterTask; @@ -47,7 +44,6 @@ 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; @@ -62,11 +58,9 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; -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; @@ -82,10 +76,8 @@ 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; @@ -100,19 +92,12 @@ 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.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 javafx.stage.FileChooser; import javafx.stage.StageStyle; import javafx.util.Duration; @@ -128,9 +113,7 @@ import java.nio.file.Paths; import java.util.ArrayList; 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; @@ -165,7 +148,7 @@ public class Hypersurface3DPane extends StackPane public Group sceneRoot = new Group(); - public Group extrasGroup = new Group(); + public Group extrasGroup; public Group debugGroup = new Group(); public Group ellipsoidGroup = new Group(); public SubScene subScene; @@ -189,19 +172,17 @@ public class Hypersurface3DPane extends StackPane 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; + private final HypersurfaceSourceModel sourceModel = + new HypersurfaceSourceModel(DEFAULT_XWIDTH, DEFAULT_SURFSCALE); + private boolean suppressRowOrientationRefresh; - //Shapley value support - private Image lastImage = null; - private String lastImageSource = null; - public List shapleyVectors = new ArrayList<>(); + // 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. @@ -209,16 +190,14 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private HypersurfaceLegendOverlay legendOverlay; private final BooleanProperty legendEnabled = new SimpleBooleanProperty(true); - //allows 2D labels to track their 3D counterparts - HashMap shape3DToLabel = new HashMap<>(); - public List featureVectors = new ArrayList<>(); + 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; activeHeightField owns render-side data. + * downsampled render grid; sourceModel.getActiveHeightField() owns render-side data. */ - public List> dataGrid = new ArrayList<>(); + public List> dataGrid = sourceModel.getDataGrid(); private Random rando = new Random(); public HyperSurfacePlotMesh surfPlot; @@ -235,43 +214,17 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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(1, 32); - private Label xLabel = new Label("Features (ordered)"); - private Label yLabel = new Label("Magnitude"); - private Label zLabel = new Label("Time (Samples)"); - - public List featureLabels = new ArrayList<>(); + + public List featureLabels = sourceModel.getFeatureLabels(); public Scene scene; - HashMap shape3DToCalloutMap; public String imageryBasePath = ""; SurfaceChartPane surfaceChartPane; public AmbientLight ambientLight; public PointLight pointLight; private AmbientLight skyboxAmbientLight; - private List> originalGrid = new ArrayList<>(); - - // ============================================================ // Primitive HeightField + Global LOD Cap (via LodManager) @@ -285,36 +238,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} /** 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 @@ -322,12 +245,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} */ 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; private final SurfaceCoordinateMapper coordinateMapper = new SurfaceCoordinateMapper(); /** Screen-space LOD scheduler/selector for the legacy/global renderer. */ @@ -361,8 +278,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public Hypersurface3DPane(Scene scene) { this.scene = scene; - interactionController = new HypersurfaceInteractionController( - this, scene, coordinateMapper, surfaceCrosshairOverlay); 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 @@ -370,14 +285,14 @@ public Hypersurface3DPane(Scene scene) { sceneProperty().addListener((obs, oldScene, newScene) -> { if (newScene != null) attachLegendOverlay(App.getAppPathPaneStack()); }); - surfaceRowOrientation.addListener((obs, oldValue, newValue) -> { + sourceModel.rowOrientationProperty().addListener((obs, oldValue, newValue) -> { applySurfaceRowOrientationToRenderers(); refreshCoordinateMapper(); if (!suppressRowOrientationRefresh && surfPlot != null) { updateTheMesh(); } }); - surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { + sourceModel.heightOrientationProperty().addListener((obs, oldValue, newValue) -> { applySurfaceHeightOrientationToRenderers(); refreshCrosshairSurfaceContext(); refreshLegendOverlay(); @@ -385,7 +300,6 @@ public Hypersurface3DPane(Scene scene) { updateTheMesh(); } }); - shape3DToCalloutMap = new HashMap<>(); ambientLight = new AmbientLight(Color.WHITE); setBackground(Background.EMPTY); @@ -394,40 +308,11 @@ public Hypersurface3DPane(Scene scene) { 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)); - 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); - - // 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); - - labelGroup.getChildren().addAll(xLabel, yLabel, zLabel); - labelGroup.setManaged(false); - shape3DToLabel.put(xSphere, xLabel); - shape3DToLabel.put(ySphere, yLabel); - shape3DToLabel.put(zSphere, zLabel); + overlayManager = new HypersurfaceOverlayManager( + this, scene, subScene, coordinateMapper, planeSize); + extrasGroup = overlayManager.getExtrasGroup(); + interactionController = new HypersurfaceInteractionController( + this, scene, coordinateMapper, surfaceCrosshairOverlay, overlayManager); camera = new PerspectiveCamera(true); cameraTransform.setTranslate(0, 0, 0); @@ -447,10 +332,9 @@ public Hypersurface3DPane(Scene scene) { cameraController.installInputHandlers(); setupSkyBox(); debugGroup.setVisible(false); - extrasGroup.setVisible(false); - labelGroup.setVisible(false); - sceneRoot.getChildren().addAll(cameraTransform, highlightedPoint, - nodeGroup, extrasGroup, debugGroup, dataXForm); + overlayManager.setInitialVisibility(false); + sceneRoot.getChildren().addAll(cameraTransform, overlayManager.getHighlightedPoint(), + overlayManager.getNodeGroup(), extrasGroup, debugGroup, dataXForm); // Add graph layer last so it draws above the surface (z-order within Group) sceneRoot.getChildren().add(graphLayer); sceneRoot.getChildren().add(surfaceCrosshairOverlay); @@ -485,7 +369,7 @@ public Hypersurface3DPane(Scene scene) { updateView(false); notifyIndexChange(); } - updateLabels(); + overlayManager.updateLabels(); } } if (keycode == KeyCode.PERIOD) { @@ -504,7 +388,7 @@ public Hypersurface3DPane(Scene scene) { updateView(false); notifyIndexChange(); } - updateLabels(); + overlayManager.updateLabels(); } else { scene.getRoot().fireEvent(new CommandTerminalEvent("Feature Index Max Reached: (" + featureSize + ")", new Font("Consolas", 20), Color.YELLOW)); @@ -516,16 +400,15 @@ public Hypersurface3DPane(Scene scene) { if (keycode == KeyCode.I) { double tz = event.isShiftDown() ? 50 : 5; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() + tz); + overlayManager.moveTimeline(tz); } if (keycode == KeyCode.K) { double tz = event.isShiftDown() ? 50 : 5; - glowLineBox.setTranslateZ(glowLineBox.getTranslateZ() - tz); + overlayManager.moveTimeline(-tz); } if (!cameraHandled) { - updateLabels(); - updateCalloutHeadPoints(subScene); + overlayManager.refreshProjectedOverlays(); requestLodUpdate(LodManager.UpdateReason.OTHER); } }); @@ -534,7 +417,7 @@ public Hypersurface3DPane(Scene scene) { surfaceChartPane = new SurfaceChartPane(scene, pathPane); bp = new BorderPane(subScene); getChildren().clear(); - getChildren().addAll(bp, labelGroup); + getChildren().addAll(bp, overlayManager.getLabelGroup()); MenuItem showControlsItem = new MenuItem("Hypersurface Controls"); @@ -625,7 +508,7 @@ public Hypersurface3DPane(Scene scene) { MenuItem navigatorItem = new MenuItem("Content Navigator"); navigatorItem.setOnAction(e -> interactionController.showContentNavigator( - imageBackedSurface ? lastImage : null)); + sourceModel.isImageBackedSurface() ? sourceModel.getSourceImage() : null)); MenuItem updateAllItem = new MenuItem("Update Render"); updateAllItem.setOnAction(e -> updateAll()); @@ -641,11 +524,8 @@ public Hypersurface3DPane(Scene scene) { }); 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()); - }); + showDataMarkersItem.setOnAction(e -> + overlayManager.setDataMarkersVisible(showDataMarkersItem.isSelected())); CheckMenuItem enableCrosshairsItem = new CheckMenuItem("Enable Crosshairs"); enableCrosshairsItem.setOnAction(e -> @@ -764,15 +644,14 @@ public Hypersurface3DPane(Scene scene) { } } tessellateImage(image, x1, y1, x2, y2); - lastImage = image; - lastImageSource = image.getUrl(); + sourceModel.setSourceImage(image); }); 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(); + overlayManager.updateLabels(); updateView(true); notifyIndexChange(); }); @@ -813,13 +692,11 @@ public Hypersurface3DPane(Scene scene) { 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(ShadowEvent.SHOW_AXES_LABELS, e -> + overlayManager.setAxesAndLabelsVisible((boolean) e.object)); scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, e -> imageryBasePath = (String) e.object); Platform.runLater(() -> { - updateLabels(); + overlayManager.updateLabels(); updateView(true); updateTheMesh(); }); @@ -1009,51 +886,19 @@ public void unrollHyperspace() { } 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()); + overlayManager.updateCalloutHeadPoint(node, callout, subScene); } public void updateCalloutHeadPoints(SubScene subScene) { - shape3DToCalloutMap.forEach((node, callout) -> updateCalloutHeadPoint(node, callout, subScene)); + overlayManager.updateCalloutHeadPoints(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; + return overlayManager.createCallout(shape3D, featureVector, subScene); } public void addCallout(Callout callout, Shape3D shape3D) { - callout.setManaged(false); - getChildren().add(callout); - shape3DToCalloutMap.put(shape3D, callout); + overlayManager.addCallout(callout, shape3D); } public void updateTheMesh() { @@ -1078,7 +923,7 @@ public void updateTheMesh() { // 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) { + if (sourceModel.isImageBackedSurface() && sourceModel.getActiveHeightField() == null) { surfPlot.setVisible(false); return; } @@ -1096,12 +941,12 @@ public void updateTheMesh() { // 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 + boolean useDirectHeightFieldMesh = sourceModel.getActiveHeightField() != null && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; long meshStart = System.nanoTime(); if (useDirectHeightFieldMesh) { - surfPlot.updateMeshHeightField(activeHeightField, + surfPlot.updateMeshHeightField(sourceModel.getActiveHeightField(), renderScaleX, yScale, renderScaleZ); System.out.println("Direct HeightField mesh built: " + renderWidth + "x" + renderHeight + " in " @@ -1114,16 +959,16 @@ public void updateTheMesh() { } 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) + List> renderGrid = sourceModel.getActiveHeightField() != null + ? SurfaceUtils.toGrid(sourceModel.getActiveHeightField()) : dataGrid; TessellationTube tube = new TessellationTube(renderGrid, Color.WHITE, yScale * 10, renderScaleZ, yScale); tube.setMouseTransparent(true); - if (null != lastImage) { + if (null != sourceModel.getSourceImage()) { tube.meshView.setDrawMode(DrawMode.FILL); tube.colorByImage = colorationMethod == COLORATION.COLOR_BY_IMAGE; - tube.updateMaterial(lastImage); + tube.updateMaterial(sourceModel.getSourceImage()); } Platform.runLater(() -> sceneRoot.getChildren().add(tube)); } @@ -1143,9 +988,9 @@ private void applyCurrentColoration() { switch (colorationMethod) { case COLOR_BY_IMAGE -> { - if (lastImage == null) return; + if (sourceModel.getSourceImage() == null) return; if (surfPlot.isDirectHeightFieldMesh()) { - surfPlot.setDirectTextureModeImage(lastImage); + surfPlot.setDirectTextureModeImage(sourceModel.getSourceImage()); } else { PhongMaterial material; if (surfPlot.getMaterial() instanceof PhongMaterial existing) { @@ -1154,7 +999,7 @@ private void applyCurrentColoration() { material = new PhongMaterial(Color.WHITE); } material.setDiffuseColor(Color.WHITE); - material.setDiffuseMap(lastImage); + material.setDiffuseMap(sourceModel.getSourceImage()); surfPlot.setMaterial(material); } } @@ -1165,9 +1010,9 @@ private void applyCurrentColoration() { // 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; + HeightField colorRangeField = !sourceModel.getLodProcessedLevels().isEmpty() + ? sourceModel.getLodProcessedLevels().get(0) + : sourceModel.getActiveHeightField(); if (colorRangeField != null) { float[] minMax = colorRangeField.minMax(); double minColorValue = minMax[0] * yScale; @@ -1242,8 +1087,7 @@ public void updateAll() { } private void handleCameraInteraction(HypersurfaceCameraController.InteractionType interactionType) { - updateLabels(); - updateCalloutHeadPoints(subScene); + overlayManager.refreshProjectedOverlays(); LodManager.UpdateReason reason = switch (interactionType) { case DRAG -> LodManager.UpdateReason.DRAG; case ZOOM -> LodManager.UpdateReason.SCROLL; @@ -1253,31 +1097,12 @@ private void handleCameraInteraction(HypersurfaceCameraController.InteractionTyp } private void handleCameraSettled() { - updateLabels(); - updateCalloutHeadPoints(subScene); + overlayManager.refreshProjectedOverlays(); forceLodUpdate(); } - 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)); - }); - } - - void updateInteractionHoverMarker(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(); - } - HeightField getActiveHeightFieldForInteraction() { - return activeHeightField; + return sourceModel.getActiveHeightField(); } List getFeatureVectorsForInteraction() { @@ -1293,15 +1118,15 @@ TiledSurfaceRenderer getTiledSurfaceRendererForInteraction() { } Image getSourceImageForInteraction() { - return lastImage; + return sourceModel.getSourceImage(); } int getImageSourceStartXForInteraction() { - return imageSourceStartX; + return sourceModel.getImageSourceStartX(); } int getImageSourceStartYForInteraction() { - return imageSourceStartY; + return sourceModel.getImageSourceStartY(); } void showSurfaceChartsPane() { @@ -1318,25 +1143,6 @@ void showSurfaceChartsPane() { } } - 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(() -> { @@ -1349,8 +1155,7 @@ public void updateView(boolean forcePNodeUpdate) { } private void generateRandos(int xWidth, int zWidth, float yScale) { - if (null == dataGrid) dataGrid = new ArrayList<>(zWidth); - else dataGrid.clear(); + dataGrid.clear(); List xList; for (int z = 0; z < zWidth; z++) { xList = new ArrayList<>(xWidth); @@ -1372,7 +1177,7 @@ private Number vertToHeight(Vert3D p) { } // Prefer primitive active LOD field - if (activeHeightField == null) { + if (sourceModel.getActiveHeightField() == null) { if (dataGrid == null) return 0.0; switch (interpMode) { case BILINEAR: @@ -1391,12 +1196,12 @@ private Number vertToHeight(Vert3D p) { switch (interpMode) { case BILINEAR: case BICUBIC: { - double sx = Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; - double sz = Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; + double sx = Double.isFinite(sourceModel.getCurrentLodSurfScaleX()) ? sourceModel.getCurrentLodSurfScaleX() : surfScale; + double sz = Double.isFinite(sourceModel.getCurrentLodSurfScaleZ()) ? sourceModel.getCurrentLodSurfScaleZ() : 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); + gy = orientRowCoordinate(gy, sourceModel.getActiveHeightField().height()); + return SurfaceUtils.sample(sourceModel.getActiveHeightField(), gx, gy, interpMode); } case NEAREST: default: @@ -1405,12 +1210,12 @@ private Number vertToHeight(Vert3D p) { } private Number lookupPoint(Vert3D p) { - if (activeHeightField != null) { - int w = activeHeightField.width(); - int h = activeHeightField.height(); + if (sourceModel.getActiveHeightField() != null) { + int w = sourceModel.getActiveHeightField().width(); + int h = sourceModel.getActiveHeightField().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]; + return (double) sourceModel.getActiveHeightField().data()[dataRow * w + p.xIndex]; } if (dataGrid == null) return 0.0; if (p.yIndex < 0 || p.yIndex >= dataGrid.size() @@ -1420,10 +1225,10 @@ private Number lookupPoint(Vert3D p) { } 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(); + // Used by cylinder mode (and legacy paths). Works on sourceModel.getActiveHeightField() if present. + if (sourceModel.getActiveHeightField() != null) { + int w = sourceModel.getActiveHeightField().width(); + int h = sourceModel.getActiveHeightField().height(); if (w <= 0 || h <= 0) return 0.0; int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; @@ -1434,7 +1239,7 @@ private Number findBlerpHeight(Vert3D p) { if (y1Index >= h - 1) y1Index = h - 1; int y2Index = p.yIndex >= h - 1 ? h - 1 : p.yIndex + 1; - float[] d = activeHeightField.data(); + float[] d = sourceModel.getActiveHeightField().data(); double c11 = d[y1Index * w + x1Index] * yScale; double c21 = d[y1Index * w + x2Index] * yScale; double c12 = d[y2Index * w + x1Index] * yScale; @@ -1465,8 +1270,6 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, return f12 + (f34 - f12) * yratio; } - Point3D vertP3D; - private void configureLightingScopes() { // Keep the camera-following point light focused on the analytical surface/graph. pointLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); @@ -1478,10 +1281,10 @@ private void configureLightingScopes() { surfPlot, tiledSurfaceRenderer, graphLayer, - nodeGroup, + overlayManager.getNodeGroup(), extrasGroup, debugGroup, - highlightedPoint, + overlayManager.getHighlightedPoint(), surfaceCrosshairOverlay); } @@ -1530,78 +1333,14 @@ private void loadSurf3D() { // created by rebuildProcessedGridAndRefresh() from the current source snapshot. rebuildProcessedGridAndRefresh(); - 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; - int maxSourceRow = Math.max(0, getSourceHeight() - 1); - anchorIndex = Math.max(0, Math.min(anchorIndex, maxSourceRow)); - glowLineBox.setTranslateZ(coordinateMapper.sourceRowToWorldZ(anchorIndex)); - 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); - } - }); + overlayManager.initializeTimelineMarkers(); - extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); wireEventHandlers(); configureLightingScopes(); 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)); - }); - }); + overlayManager.updateLabels(); } /** @@ -1647,7 +1386,7 @@ private void wireEventHandlers() { 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) { + if (surfPlot != null && sourceModel.getActiveHeightField() == null) { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } @@ -1658,7 +1397,7 @@ private void wireEventHandlers() { scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { if (syncingGuiControls) return; this.zWidth = (int) e.object; - if (surfPlot != null && activeHeightField == null) { + if (surfPlot != null && sourceModel.getActiveHeightField() == null) { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } @@ -1710,13 +1449,12 @@ private void wireEventHandlers() { if (shapleyPathChanged) { if (next == COLORATION.COLOR_BY_SHAPLEY && lodManager != null - && lodProcessedLevels != null - && !lodProcessedLevels.isEmpty()) { - activeLodIndex = -1; - activeHeightField = null; + && sourceModel.getLodProcessedLevels() != null + && !sourceModel.getLodProcessedLevels().isEmpty()) { + sourceModel.setActiveLod(-1, null); lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, baseWorldDepth, -1); + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), -1); lodManager.forceUpdate(); } else if (shouldUseTiledHeightFieldRenderer()) { configureTiledPyramid(); @@ -1791,7 +1529,8 @@ private void wireEventHandlers() { 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 -> extrasGroup.setVisible((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)); @@ -1845,9 +1584,9 @@ private void attachLegendOverlay(Pane desktopPane) { } private HypersurfaceLegendState buildLegendState() { - HeightField rangeField = lodProcessedLevels != null && !lodProcessedLevels.isEmpty() - ? lodProcessedLevels.get(0) - : activeHeightField; + HeightField rangeField = sourceModel.getLodProcessedLevels() != null && !sourceModel.getLodProcessedLevels().isEmpty() + ? sourceModel.getLodProcessedLevels().get(0) + : sourceModel.getActiveHeightField(); double minimum = Double.NaN; double maximum = Double.NaN; @@ -1857,7 +1596,7 @@ private HypersurfaceLegendState buildLegendState() { maximum = minMax[1]; } - String heightLabel = imageBackedSurface ? "Image Intensity" : "Feature Value"; + String heightLabel = sourceModel.isImageBackedSurface() ? "Image Intensity" : "Feature Value"; String colorLabel = switch (colorationMethod) { case COLOR_BY_IMAGE -> "Source Image"; case COLOR_BY_FEATURE -> "Feature Value"; @@ -1919,33 +1658,7 @@ public BooleanProperty legendEnabledProperty() { } 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); + overlayManager.updateCalloutByFeatureVector(callout, featureVector); } public void clearAll() { @@ -1957,13 +1670,10 @@ public void clearAll() { new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); notifyIndexChange(); ellipsoidGroup.getChildren().clear(); - shape3DToLabel.clear(); - shape3DToLabel.put(xSphere, xLabel); - shape3DToLabel.put(ySphere, yLabel); - shape3DToLabel.put(zSphere, zLabel); + overlayManager.resetLabelTracking(); dataGrid.clear(); featureVectors.clear(); - originalGrid.clear(); + sourceModel.clearOriginalGrid(); interactionController.resetSelectionState(); resetLodDataState(); } @@ -1997,7 +1707,8 @@ public void showFA3D() { @Override public void setFeatureCollection(FeatureCollection fc) { - featureVectors = fc.getFeatures(); + featureVectors.clear(); + featureVectors.addAll(fc.getFeatures()); interactionController.resetSelectionState(); } @@ -2078,23 +1789,8 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection 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(); + overlayManager.updateAxisExtents(); + overlayManager.updateTimelineGeometry(surfPlot.getMaxAbsY() * 2); } @Override @@ -2119,8 +1815,7 @@ public void clearSemanticMaps() { @Override public void addFeatureCollection(FeatureCollection featureCollection, boolean clearQueue) { - if (null == dataGrid) dataGrid = new ArrayList<>(featureCollection.getFeatures().size()); - else dataGrid.clear(); + dataGrid.clear(); List xList; for (FeatureVector fv : featureCollection.getFeatures()) { xList = new ArrayList<>(fv.getData().size()); @@ -2133,7 +1828,8 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl captureDataGridAsSource(); rebuildProcessedGridAndRefresh(); getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); - featureVectors = featureCollection.getFeatures(); + featureVectors.clear(); + featureVectors.addAll(featureCollection.getFeatures()); interactionController.resetSelectionState(); } @@ -2154,7 +1850,7 @@ public void locateFeatureVector(FeatureVector featureVector) { public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); - originalGrid.clear(); + sourceModel.clearOriginalGrid(); interactionController.resetSelectionState(); resetLodDataState(); } @@ -2192,7 +1888,8 @@ public void refresh(boolean forceNodeUpdate) { @Override public void setDimensionLabels(List labelStrings) { - featureLabels = labelStrings; + featureLabels.clear(); + if (labelStrings != null) featureLabels.addAll(labelStrings); } @Override @@ -2289,38 +1986,17 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { // 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 + // 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(); - 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, + HeightField rawL0 = 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(); + sourceModel.activateImageSource( + image, startX, startY, sourceWidth, sourceHeight, rawL0); interactionController.resetSelectionState(); - 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); @@ -2331,8 +2007,7 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { zWidth = sourceHeight; syncGuiControls(); rebuildProcessedGridAndRefresh(); - xSphere.setTranslateX(getWorldWidth() / 2.0); - zSphere.setTranslateZ(getWorldDepth() / 2.0); + overlayManager.updateAxisExtents(); interactionController.publishSourceImageChanged(image); Utils.printTotalTime(startTime); } @@ -2340,7 +2015,6 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { @Override public void addShapleyCollection(ShapleyCollection shapleyCollection) { shapleyVectors.clear(); - lastImageSource = shapleyCollection.getSourceInput(); shapleyVectors.addAll(shapleyCollection.getValues()); try { WritableImage wi = ResourceUtils.loadImageFile(imageryBasePath + shapleyCollection.getSourceInput()); @@ -2348,7 +2022,7 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { int x2 = (int) wi.getWidth(); int y2 = (int) wi.getHeight(); tessellateImage(wi, 0, 0, x2, y2); - lastImage = wi; + sourceModel.setSourceImage(wi); LOG.info("injecting Shapley function values into Vertices... "); long startTime = System.nanoTime(); surfPlot.functionValues.clear(); @@ -2378,19 +2052,11 @@ 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; - } + // ================= source-model orchestration ================= /** - * 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. + * Releases renderer-side objects derived from the previous image, then drops + * the corresponding image/HeightField state from the source model. */ private void releaseImageBackedStateForReplacement() { if (tiledSurfaceRenderer != null) { @@ -2401,66 +2067,23 @@ private void releaseImageBackedStateForReplacement() { surfPlot.setVisible(false); } surfaceCrosshairOverlay.hide(); - - 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; + sourceModel.releaseImageBackedStateForReplacement(); } - /** - * 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. - */ + /** Snapshot the current boxed working grid as the authoritative data source. */ private void captureDataGridAsSource() { - // Boxed grids represent data/FeatureCollection-backed surfaces, whose historical - // world-size behavior remains sourceDimension * surfScale. - boolean wasImageBacked = imageBackedSurface; - imageBackedSurface = false; + boolean wasImageBacked = sourceModel.captureDataGridAsSource(); if (wasImageBacked) { setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); } - if (dataGrid == null || dataGrid.isEmpty()) { - originalGrid = new ArrayList<>(); + if (dataGrid.isEmpty()) { resetLodDataState(); - return; } - originalGrid = deepCopyGrid(dataGrid); - processingSourceHeightField = null; - imageSourceStartX = 0; - imageSourceStartY = 0; - imageSourceWidth = 0; - imageSourceHeight = 0; } private void resetLodDataState() { - imageBackedSurface = false; + sourceModel.resetLodDataState(); 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); @@ -2469,114 +2092,70 @@ private void resetLodDataState() { } public boolean isImageBackedSurface() { - return imageBackedSurface; + return sourceModel.isImageBackedSurface(); } public int getRenderL0Width() { - return lodProcessedLevels != null && !lodProcessedLevels.isEmpty() - ? lodProcessedLevels.get(0).width() - : getRenderWidth(); + return sourceModel.getRenderL0Width(xWidth); } public int getRenderL0Height() { - return lodProcessedLevels != null && !lodProcessedLevels.isEmpty() - ? lodProcessedLevels.get(0).height() - : getRenderHeight(); + return sourceModel.getRenderL0Height(zWidth); } public 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); + return sourceModel.getSourceWidth(xWidth); } public 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); + return sourceModel.getSourceHeight(zWidth); } public int getRenderWidth() { - return activeHeightField != null ? activeHeightField.width() : Math.max(1, xWidth); + return sourceModel.getRenderWidth(xWidth); } public int getRenderHeight() { - return activeHeightField != null ? activeHeightField.height() : Math.max(1, zWidth); + return sourceModel.getRenderHeight(zWidth); } private double getRenderScaleX() { - return Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; + return sourceModel.getRenderScaleX(surfScale); } private double getRenderScaleZ() { - return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; + return sourceModel.getRenderScaleZ(surfScale); } private HypersurfaceCameraController.SurfaceBounds getCameraSurfaceBounds() { double maxAbsY = 0.0; - if (activeHeightField != null) { - float[] minMax = activeHeightField.minMax(); + 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 (processingSourceHeightField != null) { - float[] minMax = processingSourceHeightField.minMax(); + } else if (processingField != null) { + float[] minMax = processingField.minMax(); maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); } else if (surfPlot != null) { maxAbsY = Math.abs(surfPlot.getMaxAbsY()); } - // Height is deliberately symmetric around Y=0. Hypersurface scalar values are - // often one-sided, so using 2*maxAbsY guarantees the fit sphere remains centered - // on the same origin used by camera rotation and panning. double worldHeight = Math.max(1.0, maxAbsY * 2.0); return new HypersurfaceCameraController.SurfaceBounds( - getWorldWidth(), - worldHeight, - getWorldDepth()); + getWorldWidth(), worldHeight, getWorldDepth()); } public double getWorldWidth() { - if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; - int sourceWidth = getSourceWidth(); - int sourceHeight = getSourceHeight(); - return calculateWorldWidth(sourceWidth, sourceHeight); + return sourceModel.getWorldWidth(xWidth, zWidth, surfScale); } public 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; + return sourceModel.getWorldDepth(xWidth, zWidth, surfScale); } private void recomputeBaseWorldExtents() { - int sourceWidth = getSourceWidth(); - int sourceHeight = getSourceHeight(); - baseWorldWidth = calculateWorldWidth(sourceWidth, sourceHeight); - baseWorldDepth = calculateWorldDepth(sourceWidth, sourceHeight); + sourceModel.recomputeBaseWorldExtents(xWidth, zWidth, surfScale); refreshCoordinateMapper(); } @@ -2594,7 +2173,7 @@ private void refreshCoordinateMapper() { private void refreshCrosshairSurfaceContext() { surfaceCrosshairOverlay.configureSurface( - activeHeightField, + sourceModel.getActiveHeightField(), coordinateMapper, yScale, getSurfaceRowOrientation(), @@ -2602,59 +2181,40 @@ private void refreshCrosshairSurfaceContext() { } 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); + return sourceModel.getActiveRenderValue(row, column); } 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; + return sourceModel.getActiveRenderRow(row); } 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; + return sourceModel.getActiveRenderColumn(column); } /** 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(); + HeightField activeField = sourceModel.getActiveHeightField(); + if (activeField != null) { + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / activeField.width(), + sourceModel.getBaseWorldDepth() / activeField.height()); } if (surfPlot != null) { - surfPlot.setTranslateX(-baseWorldWidth / 2.0); - surfPlot.setTranslateZ(-baseWorldDepth / 2.0); + surfPlot.setTranslateX(-sourceModel.getBaseWorldWidth() / 2.0); + surfPlot.setTranslateZ(-sourceModel.getBaseWorldDepth() / 2.0); } if (shouldUseTiledHeightFieldRenderer()) { configureTiledPyramid(); tiledSurfaceRenderer.forceUpdate(); - } else if (lodManager != null && lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { + } else if (lodManager != null && !sourceModel.getLodProcessedLevels().isEmpty()) { lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, - baseWorldDepth, - activeLodIndex - ); + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), + sourceModel.getBaseWorldDepth(), + sourceModel.getActiveLodIndex()); lodManager.forceUpdate(); } } @@ -2664,7 +2224,7 @@ private void refreshWorldExtentsAndLodMetadata() { * surface scale. Image aspect ratio determines the other axis. */ public double getNominalImageWorldExtent() { - return nominalImageWorldExtent; + return sourceModel.getNominalImageWorldExtent(); } /** @@ -2672,29 +2232,25 @@ public double getNominalImageWorldExtent() { * 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) { + if (Double.compare(sourceModel.getNominalImageWorldExtent(), nominalImageWorldExtent) == 0) return; + sourceModel.setNominalImageWorldExtent(nominalImageWorldExtent); + if (sourceModel.isImageBackedSurface() + && sourceModel.getProcessingSourceHeightField() != null) { refreshWorldExtentsAndLodMetadata(); updateTheMesh(); } } public SurfaceRowOrientation getSurfaceRowOrientation() { - return surfaceRowOrientation.get(); + return sourceModel.getRowOrientation(); } public void setSurfaceRowOrientation(SurfaceRowOrientation orientation) { - surfaceRowOrientation.set(orientation != null - ? orientation - : SurfaceRowOrientation.FIRST_ROW_NEAR); + sourceModel.setRowOrientation(orientation); } public ObjectProperty surfaceRowOrientationProperty() { - return surfaceRowOrientation; + return sourceModel.rowOrientationProperty(); } private void setSourceDefaultRowOrientation(SurfaceRowOrientation orientation) { @@ -2713,17 +2269,15 @@ private void applySurfaceRowOrientationToRenderers() { } public SurfaceHeightOrientation getSurfaceHeightOrientation() { - return surfaceHeightOrientation.get(); + return sourceModel.getHeightOrientation(); } public void setSurfaceHeightOrientation(SurfaceHeightOrientation orientation) { - surfaceHeightOrientation.set(orientation != null - ? orientation - : SurfaceHeightOrientation.HIGH_VALUES_UP); + sourceModel.setHeightOrientation(orientation); } public ObjectProperty surfaceHeightOrientationProperty() { - return surfaceHeightOrientation; + return sourceModel.heightOrientationProperty(); } private void applySurfaceHeightOrientationToRenderers() { @@ -2733,19 +2287,11 @@ private void applySurfaceHeightOrientationToRenderers() { } 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; + return sourceModel.orientRowIndex(logicalRow, rowCount); } 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; + return sourceModel.orientRowCoordinate(logicalRow, rowCount); } public boolean isTiledHeightFieldRenderingEnabled() { @@ -2755,15 +2301,14 @@ public boolean isTiledHeightFieldRenderingEnabled() { public void setTiledHeightFieldRenderingEnabled(boolean enabled) { if (this.tiledHeightFieldRenderingEnabled == enabled) return; this.tiledHeightFieldRenderingEnabled = enabled; - if (lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { + if (sourceModel.getLodProcessedLevels() != null && !sourceModel.getLodProcessedLevels().isEmpty()) { if (enabled && shouldUseTiledHeightFieldRenderer()) { configureTiledPyramid(); } else if (lodManager != null) { - activeLodIndex = -1; - activeHeightField = null; + sourceModel.setActiveLod(-1, null); lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, baseWorldDepth, -1); + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), -1); lodManager.forceUpdate(); } updateTheMesh(); @@ -2818,12 +2363,12 @@ public void setMaxRenderResolution(int maxRenderResolution) { } if (this.maxRenderResolution == maxRenderResolution) return; this.maxRenderResolution = maxRenderResolution; - if (imageBackedSurface && lastImage != null && imageSourceWidth > 0 && imageSourceHeight > 0) { - PixelReader reader = lastImage.getPixelReader(); + if (sourceModel.isImageBackedSurface() && sourceModel.getSourceImage() != null && sourceModel.getImageSourceWidth() > 0 && sourceModel.getImageSourceHeight() > 0) { + PixelReader reader = sourceModel.getSourceImage().getPixelReader(); if (reader != null) { - processingSourceHeightField = buildImageRawL0(reader, - imageSourceStartX, imageSourceStartY, - imageSourceWidth, imageSourceHeight, maxRenderResolution); + sourceModel.setProcessingSourceHeightField(buildImageRawL0(reader, + sourceModel.getImageSourceStartX(), sourceModel.getImageSourceStartY(), + sourceModel.getImageSourceWidth(), sourceModel.getImageSourceHeight(), maxRenderResolution)); } } rebuildProcessedGridAndRefresh(); @@ -2847,10 +2392,10 @@ public void setMinRenderResolution(int minRenderResolution) { private boolean shouldUseTiledHeightFieldRenderer() { return tiledHeightFieldRenderingEnabled - && imageBackedSurface + && sourceModel.isImageBackedSurface() && tiledSurfaceRenderer != null - && lodProcessedLevels != null - && !lodProcessedLevels.isEmpty() + && sourceModel.getLodProcessedLevels() != null + && !sourceModel.getLodProcessedLevels().isEmpty() && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; } @@ -2889,10 +2434,10 @@ private void configureTiledAppearance() { 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(); + if (colorationMethod == COLORATION.COLOR_BY_IMAGE && sourceModel.getSourceImage() != null) { + tiledSurfaceRenderer.setColorByImage(sourceModel.getSourceImage()); + } else if (colorationMethod == COLORATION.COLOR_BY_FEATURE && !sourceModel.getLodProcessedLevels().isEmpty()) { + float[] minMax = sourceModel.getLodProcessedLevels().get(0).minMax(); double minColorValue = minMax[0] * yScale; double maxColorValue = minMax[1] * yScale; if (!(maxColorValue > minColorValue)) { @@ -2907,17 +2452,17 @@ private void configureTiledAppearance() { private void configureTiledPyramid() { if (!shouldUseTiledHeightFieldRenderer()) return; - activeLodIndex = 0; - activeHeightField = lodProcessedLevels.get(0); - xWidth = activeHeightField.width(); - zWidth = activeHeightField.height(); - currentLodSurfScaleX = baseWorldWidth / xWidth; - currentLodSurfScaleZ = baseWorldDepth / zWidth; + sourceModel.setActiveLod(0, sourceModel.getLodProcessedLevels().get(0)); + xWidth = sourceModel.getActiveHeightField().width(); + zWidth = sourceModel.getActiveHeightField().height(); + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / xWidth, + sourceModel.getBaseWorldDepth() / zWidth); refreshCoordinateMapper(); tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); tiledSurfaceRenderer.setPyramid( - lodProcessedLevels, baseWorldWidth, baseWorldDepth, yScale); + sourceModel.getLodProcessedLevels(), sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), yScale); configureTiledAppearance(); tiledSurfaceRenderer.forceUpdate(); syncGuiControls(); @@ -2929,15 +2474,16 @@ private void rebuildProcessedGridAndRefresh() { // 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); + // sourceModel.getOriginalGrid() and are converted lazily here for compatibility. + if (sourceModel.getProcessingSourceHeightField() == null) { + if (sourceModel.getOriginalGrid() == null || sourceModel.getOriginalGrid().isEmpty()) return; + sourceModel.setProcessingSourceHeightField( + SurfaceUtils.toHeightField(sourceModel.getOriginalGrid())); } - HeightField l0Raw = imageBackedSurface - ? processingSourceHeightField - : SurfaceUtils.buildL0Raw(processingSourceHeightField, maxRenderResolution); + HeightField l0Raw = sourceModel.isImageBackedSurface() + ? sourceModel.getProcessingSourceHeightField() + : SurfaceUtils.buildL0Raw(sourceModel.getProcessingSourceHeightField(), maxRenderResolution); SurfaceUtils.Smoothing sm = smoothingEnabled ? smoothingMethod @@ -2956,15 +2502,15 @@ private void rebuildProcessedGridAndRefresh() { toneParam ); - lodProcessedLevels = imageBackedSurface + sourceModel.setLodProcessedLevels(sourceModel.isImageBackedSurface() ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) - : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); + : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution)); - if (imageBackedSurface) { + if (sourceModel.isImageBackedSurface()) { StringBuilder lodSummary = new StringBuilder("Hypersurface tiled LOD pyramid: "); - for (int i = 0; i < lodProcessedLevels.size(); i++) { + for (int i = 0; i < sourceModel.getLodProcessedLevels().size(); i++) { if (i > 0) lodSummary.append(", "); - HeightField level = lodProcessedLevels.get(i); + HeightField level = sourceModel.getLodProcessedLevels().get(i); lodSummary.append('L').append(i).append('=') .append(level.width()).append('x').append(level.height()); } @@ -2980,19 +2526,17 @@ private void rebuildProcessedGridAndRefresh() { // 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; + sourceModel.setActiveLod(-1, null); + sourceModel.clearCurrentLodSurfScale(); if (shouldUseTiledHeightFieldRenderer()) { configureTiledPyramid(); updateTheMesh(); } else if (lodManager != null) { lodManager.setPyramid( - LodManager.fromHeightFields(lodProcessedLevels), - baseWorldWidth, - baseWorldDepth, + LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), + sourceModel.getBaseWorldWidth(), + sourceModel.getBaseWorldDepth(), -1 ); lodManager.forceUpdate(); @@ -3009,32 +2553,32 @@ private void rebuildProcessedGridAndRefresh() { * Invoked by LodManager when it selects a new level. */ private void applyActiveLodIndex(int idx) { - if (lodProcessedLevels == null || lodProcessedLevels.isEmpty()) return; + if (sourceModel.getLodProcessedLevels() == null || sourceModel.getLodProcessedLevels().isEmpty()) return; if (idx < 0) idx = 0; - if (idx >= lodProcessedLevels.size()) idx = lodProcessedLevels.size() - 1; + if (idx >= sourceModel.getLodProcessedLevels().size()) idx = sourceModel.getLodProcessedLevels().size() - 1; - HeightField selected = lodProcessedLevels.get(idx); - if (idx == activeLodIndex && activeHeightField == selected) return; + HeightField selected = sourceModel.getLodProcessedLevels().get(idx); + if (idx == sourceModel.getActiveLodIndex() && sourceModel.getActiveHeightField() == selected) return; - activeLodIndex = idx; - activeHeightField = selected; + sourceModel.setActiveLod(idx, 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(); + // while dataGrid/sourceModel.getOriginalGrid() retain full source/working data. + xWidth = sourceModel.getActiveHeightField().width(); + zWidth = sourceModel.getActiveHeightField().height(); - currentLodSurfScaleX = baseWorldWidth / xWidth; - currentLodSurfScaleZ = baseWorldDepth / zWidth; + sourceModel.setCurrentLodSurfScale( + sourceModel.getBaseWorldWidth() / xWidth, + sourceModel.getBaseWorldDepth() / zWidth); refreshCoordinateMapper(); - System.out.println("Applying Hypersurface LOD L" + activeLodIndex + System.out.println("Applying Hypersurface LOD L" + sourceModel.getActiveLodIndex() + ": " + xWidth + "x" + zWidth - + ", cellScale=" + currentLodSurfScaleX + "x" + currentLodSurfScaleZ); + + ", cellScale=" + sourceModel.getCurrentLodSurfScaleX() + "x" + sourceModel.getCurrentLodSurfScaleZ()); if (surfPlot != null) { - surfPlot.setTranslateX(-baseWorldWidth / 2.0); - surfPlot.setTranslateZ(-baseWorldDepth / 2.0); + surfPlot.setTranslateX(-sourceModel.getBaseWorldWidth() / 2.0); + surfPlot.setTranslateZ(-sourceModel.getBaseWorldDepth() / 2.0); } syncGuiControls(); 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 index 69f0099a..fccb0e05 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -59,6 +59,7 @@ public record SurfaceInteractionContext( private final Scene scene; private final SurfaceCoordinateMapper coordinateMapper; private final SurfaceCrosshairOverlay surfaceCrosshairOverlay; + private final HypersurfaceOverlayManager overlayManager; private boolean hoverEnabled; private boolean surfaceChartsEnabled; @@ -83,11 +84,13 @@ public record SurfaceInteractionContext( public HypersurfaceInteractionController(Hypersurface3DPane pane, Scene scene, SurfaceCoordinateMapper coordinateMapper, - SurfaceCrosshairOverlay surfaceCrosshairOverlay) { + SurfaceCrosshairOverlay surfaceCrosshairOverlay, + HypersurfaceOverlayManager overlayManager) { this.pane = pane; this.scene = scene; this.coordinateMapper = coordinateMapper; this.surfaceCrosshairOverlay = surfaceCrosshairOverlay; + this.overlayManager = overlayManager; wireGraphInteractionHandlers(); } @@ -266,7 +269,7 @@ public void setGraph(GraphDirectedCollection graph) { 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. - pane.updateInteractionHoverMarker(surfacePoint); + overlayManager.updateHoverMarker(surfacePoint); int renderRow = coordinateMapper.surfaceZToRenderRow(surfacePoint.getZ()); int renderColumn = coordinateMapper.surfaceXToRenderColumn(surfacePoint.getX()); 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..48047946 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java @@ -0,0 +1,425 @@ +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)); + owner.setSpheroidAnchor(true, 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/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; + } +} From bf003ac9b0a0527480734b24c059ed09e375fe29 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Fri, 9 Oct 2026 22:24:49 -0400 Subject: [PATCH 08/10] migrated processing and rendering control logic to dedicated controllers. --- .../hypersurface/Hypersurface3DPane.java | 838 ++---------------- .../HypersurfaceProcessingController.java | 417 +++++++++ .../HypersurfaceRenderController.java | 558 ++++++++++++ 3 files changed, 1055 insertions(+), 758 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceProcessingController.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceRenderController.java 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 index 48e60bc9..6f81eca7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -26,10 +26,8 @@ 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.Vert3D; import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; 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; @@ -42,7 +40,6 @@ 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.ResourceUtils; import edu.jhuapl.trinity.utils.Utils; @@ -93,7 +90,6 @@ import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; -import javafx.scene.paint.PhongMaterial; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; import javafx.scene.shape.Shape3D; @@ -101,7 +97,6 @@ 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; @@ -116,7 +111,6 @@ import java.util.List; import java.util.Optional; import java.util.Random; -import java.util.function.Function; /** * @author Sean Phillips @@ -174,6 +168,8 @@ 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 @@ -207,13 +203,6 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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); - private Skybox skybox; BorderPane bp; @@ -227,17 +216,9 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private AmbientLight skyboxAmbientLight; // ============================================================ - // Primitive HeightField + Global LOD Cap (via LodManager) + // Surface renderer / LOD orchestration // ============================================================ - /** 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; - /** * True only while model state is being pushed into HypersurfaceControlsPane. * GUI control listeners echo their changes back as HypersurfaceEvents, so those @@ -247,24 +228,8 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private final SurfaceCoordinateMapper coordinateMapper = new SurfaceCoordinateMapper(); - /** 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; + private final HypersurfaceRenderController renderController; + // --- Graph layer support --- private final Group graphLayer = new Group(); // sits in sceneRoot private boolean graphVisible = true; @@ -285,21 +250,6 @@ public Hypersurface3DPane(Scene scene) { sceneProperty().addListener((obs, oldScene, newScene) -> { if (newScene != null) attachLegendOverlay(App.getAppPathPaneStack()); }); - sourceModel.rowOrientationProperty().addListener((obs, oldValue, newValue) -> { - applySurfaceRowOrientationToRenderers(); - refreshCoordinateMapper(); - if (!suppressRowOrientationRefresh && surfPlot != null) { - updateTheMesh(); - } - }); - sourceModel.heightOrientationProperty().addListener((obs, oldValue, newValue) -> { - applySurfaceHeightOrientationToRenderers(); - refreshCrosshairSurfaceContext(); - refreshLegendOverlay(); - if (surfPlot != null) { - updateTheMesh(); - } - }); ambientLight = new AmbientLight(Color.WHITE); setBackground(Background.EMPTY); @@ -329,6 +279,23 @@ public Hypersurface3DPane(Scene scene) { this::getCameraSurfaceBounds, this::handleCameraInteraction, this::handleCameraSettled); + renderController = new HypersurfaceRenderController( + this, sourceModel, processingController, surfaceCrosshairOverlay, this::refreshCoordinateMapper); + 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(); + } + }); cameraController.installInputHandlers(); setupSkyBox(); debugGroup.setVisible(false); @@ -395,8 +362,8 @@ public Hypersurface3DPane(Scene scene) { } } 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.Y) renderController.scaleHeight(1.1); + if (keycode == KeyCode.H) renderController.scaleHeight(0.9); if (keycode == KeyCode.I) { double tz = event.isShiftDown() ? 50 : 5; @@ -812,7 +779,7 @@ public void computeCosineDistance(FeatureCollection collection) { private void applySurfaceGridToHypersurface(List> grid) { double userScale = 1.0; // future: user control - List> scaled = DataUtils.normalizeAndScale(grid, heightMode, userScale); + List> scaled = processingController.normalizeAndScale(grid, userScale); dataGrid.clear(); dataGrid.addAll(scaled); captureDataGridAsSource(); @@ -902,136 +869,11 @@ public void addCallout(Callout callout, Shape3D shape3D) { } public void updateTheMesh() { - if (surfPlot == null) return; - - surfaceCrosshairOverlay.hide(); - sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); - - final boolean useTiledRenderer = surfaceRender && shouldUseTiledHeightFieldRenderer(); - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.setVisible(useTiledRenderer); - } - if (useTiledRenderer) { - surfPlot.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 (sourceModel.isImageBackedSurface() && sourceModel.getActiveHeightField() == null) { - surfPlot.setVisible(false); - return; - } - - surfPlot.setVisible(surfaceRender); - - 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 = sourceModel.getActiveHeightField() != null - && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; - - long meshStart = System.nanoTime(); - if (useDirectHeightFieldMesh) { - surfPlot.updateMeshHeightField(sourceModel.getActiveHeightField(), - 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 = sourceModel.getActiveHeightField() != null - ? SurfaceUtils.toGrid(sourceModel.getActiveHeightField()) - : dataGrid; - TessellationTube tube = new TessellationTube(renderGrid, Color.WHITE, - yScale * 10, renderScaleZ, yScale); - tube.setMouseTransparent(true); - if (null != sourceModel.getSourceImage()) { - tube.meshView.setDrawMode(DrawMode.FILL); - tube.colorByImage = colorationMethod == COLORATION.COLOR_BY_IMAGE; - tube.updateMaterial(sourceModel.getSourceImage()); - } - Platform.runLater(() -> sceneRoot.getChildren().add(tube)); - } + renderController.updateMesh(); } - /** - * 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 (sourceModel.getSourceImage() == null) return; - if (surfPlot.isDirectHeightFieldMesh()) { - surfPlot.setDirectTextureModeImage(sourceModel.getSourceImage()); - } else { - PhongMaterial material; - if (surfPlot.getMaterial() instanceof PhongMaterial existing) { - material = existing; - } else { - material = new PhongMaterial(Color.WHITE); - } - material.setDiffuseColor(Color.WHITE); - material.setDiffuseMap(sourceModel.getSourceImage()); - 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 = !sourceModel.getLodProcessedLevels().isEmpty() - ? sourceModel.getLodProcessedLevels().get(0) - : sourceModel.getActiveHeightField(); - 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); - } + renderController.applyCurrentColoration(); } private void setupSkyBox() { @@ -1114,7 +956,7 @@ List getFeatureLabelsForInteraction() { } TiledSurfaceRenderer getTiledSurfaceRendererForInteraction() { - return tiledSurfaceRenderer; + return renderController.getTiledSurfaceRenderer(); } Image getSourceImageForInteraction() { @@ -1164,122 +1006,17 @@ private void generateRandos(int xWidth, int zWidth, float yScale) { } } - 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 (sourceModel.getActiveHeightField() == 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(sourceModel.getCurrentLodSurfScaleX()) ? sourceModel.getCurrentLodSurfScaleX() : surfScale; - double sz = Double.isFinite(sourceModel.getCurrentLodSurfScaleZ()) ? sourceModel.getCurrentLodSurfScaleZ() : 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, sourceModel.getActiveHeightField().height()); - return SurfaceUtils.sample(sourceModel.getActiveHeightField(), gx, gy, interpMode); - } - case NEAREST: - default: - return lookupPoint(p); - } - } - - private Number lookupPoint(Vert3D p) { - if (sourceModel.getActiveHeightField() != null) { - int w = sourceModel.getActiveHeightField().width(); - int h = sourceModel.getActiveHeightField().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) sourceModel.getActiveHeightField().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 sourceModel.getActiveHeightField() if present. - if (sourceModel.getActiveHeightField() != null) { - int w = sourceModel.getActiveHeightField().width(); - int h = sourceModel.getActiveHeightField().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 = sourceModel.getActiveHeightField().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; - } - private void configureLightingScopes() { // Keep the camera-following point light focused on the analytical surface/graph. - pointLight.getScope().setAll(surfPlot, tiledSurfaceRenderer, graphLayer); + pointLight.getScope().setAll( + surfPlot, renderController.getTiledSurfaceRenderer(), graphLayer); // 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, - tiledSurfaceRenderer, + renderController.getTiledSurfaceRenderer(), graphLayer, overlayManager.getNodeGroup(), extrasGroup, @@ -1292,42 +1029,10 @@ 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); - sceneRoot.getChildren().add(tiledSurfaceRenderer); - } - - interactionController.installSurfacePicking(surfPlot, tiledSurfaceRenderer); + 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. @@ -1384,12 +1089,7 @@ private void wireEventHandlers() { 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 && sourceModel.getActiveHeightField() == null) { - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - } + renderController.updateRawSurfaceTranslation(); refreshCoordinateMapper(); updateTheMesh(); }); @@ -1397,10 +1097,7 @@ private void wireEventHandlers() { scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { if (syncingGuiControls) return; this.zWidth = (int) e.object; - if (surfPlot != null && sourceModel.getActiveHeightField() == null) { - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - } + renderController.updateRawSurfaceTranslation(); refreshCoordinateMapper(); updateTheMesh(); }); @@ -1408,7 +1105,7 @@ private void wireEventHandlers() { scene.addEventHandler(HypersurfaceEvent.Y_SCALE_CHANGED, e -> { if (syncingGuiControls) return; this.yScale = ((Double) e.object).floatValue(); - if (surfPlot != null) surfPlot.setFunctionScale(yScale); + renderController.setFunctionScale(yScale); refreshCrosshairSurfaceContext(); refreshLegendOverlay(); updateTheMesh(); @@ -1427,80 +1124,54 @@ private void wireEventHandlers() { 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); + renderController.setDrawMode((DrawMode) e.object); }); scene.addEventHandler(HypersurfaceEvent.CULL_FACE_CHANGED, e -> { - CullFace cullFace = (CullFace) e.object; - if (surfPlot != null) surfPlot.setCullFace(cullFace); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setCullFace(cullFace); + renderController.setCullFace((CullFace) e.object); }); 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 - && 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(); - } - updateTheMesh(); - } else { - applyCurrentColoration(); - } + renderController.handleColorationChanged(previous, next); refreshLegendOverlay(); }); // Processing pipeline scene.addEventHandler(HypersurfaceEvent.HEIGHT_MODE_CHANGED, e -> { - this.heightMode = (HeightMode) e.object; + processingController.setHeightMode((HeightMode) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.SMOOTHING_ENABLE_CHANGED, e -> { - this.smoothingEnabled = (boolean) e.object; + processingController.setSmoothingEnabled((boolean) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.SMOOTHING_METHOD_CHANGED, e -> { - this.smoothingMethod = (SurfaceUtils.Smoothing) e.object; + processingController.setSmoothingMethod((SurfaceUtils.Smoothing) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.SMOOTHING_RADIUS_CHANGED, e -> { - this.smoothingRadius = (int) e.object; + processingController.setSmoothingRadius((int) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.GAUSSIAN_SIGMA_CHANGED, e -> { - this.gaussianSigma = (double) e.object; + processingController.setGaussianSigma((double) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.INTERP_MODE_CHANGED, e -> { - this.interpMode = (SurfaceUtils.Interpolation) e.object; + processingController.setInterpolationMode((SurfaceUtils.Interpolation) e.object); updateTheMesh(); }); scene.addEventHandler(HypersurfaceEvent.TONEMAP_ENABLE_CHANGED, e -> { - this.toneEnabled = (boolean) e.object; + processingController.setToneEnabled((boolean) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.TONEMAP_OPERATOR_CHANGED, e -> { - this.toneOperator = (SurfaceUtils.ToneMap) e.object; + processingController.setToneOperator((SurfaceUtils.ToneMap) e.object); rebuildProcessedGridAndRefresh(); }); scene.addEventHandler(HypersurfaceEvent.TONEMAP_PARAM_CHANGED, e -> { - this.toneParam = (double) e.object; + processingController.setToneParam((double) e.object); rebuildProcessedGridAndRefresh(); }); @@ -1518,10 +1189,7 @@ private void wireEventHandlers() { if (pointLight != null) pointLight.setColor((Color) e.object); }); 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); + renderController.setSpecularColor((Color) e.object); }); // UX toggles @@ -1897,66 +1565,6 @@ 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(); @@ -1992,8 +1600,8 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { // 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 = buildImageRawL0(pixelReader, - startX, startY, sourceWidth, sourceHeight, maxRenderResolution); + HeightField rawL0 = processingController.buildImageRawL0( + pixelReader, startX, startY, sourceWidth, sourceHeight); sourceModel.activateImageSource( image, startX, startY, sourceWidth, sourceHeight, rawL0); interactionController.resetSelectionState(); @@ -2025,10 +1633,7 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { sourceModel.setSourceImage(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); - } + renderController.updateShapleyFunctionValues(shapleyVectors, yScale); Utils.printTotalTime(startTime); if (colorationMethod == COLORATION.COLOR_BY_SHAPLEY) { // Rebuild through the legacy metadata path so p.f remains available. @@ -2059,14 +1664,7 @@ public void clearShapleyVectors() { * the corresponding image/HeightField state from the source model. */ private void releaseImageBackedStateForReplacement() { - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.clearSurface(); - tiledSurfaceRenderer.setVisible(false); - } - if (surfPlot != null) { - surfPlot.setVisible(false); - } - surfaceCrosshairOverlay.hide(); + renderController.releaseImageBackedRenderStateForReplacement(); sourceModel.releaseImageBackedStateForReplacement(); } @@ -2084,10 +1682,7 @@ private void captureDataGridAsSource() { private void resetLodDataState() { sourceModel.resetLodDataState(); setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); - if (tiledSurfaceRenderer != null) { - tiledSurfaceRenderer.clearSurface(); - tiledSurfaceRenderer.setVisible(false); - } + renderController.resetLodRenderState(); refreshLegendOverlay(); } @@ -2119,14 +1714,6 @@ public int getRenderHeight() { return sourceModel.getRenderHeight(zWidth); } - private double getRenderScaleX() { - return sourceModel.getRenderScaleX(surfScale); - } - - private double getRenderScaleZ() { - return sourceModel.getRenderScaleZ(surfScale); - } - private HypersurfaceCameraController.SurfaceBounds getCameraSurfaceBounds() { double maxAbsY = 0.0; HeightField activeField = sourceModel.getActiveHeightField(); @@ -2137,8 +1724,8 @@ private HypersurfaceCameraController.SurfaceBounds getCameraSurfaceBounds() { } else if (processingField != null) { float[] minMax = processingField.minMax(); maxAbsY = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); - } else if (surfPlot != null) { - maxAbsY = Math.abs(surfPlot.getMaxAbsY()); + } else { + maxAbsY = renderController.getSurfaceMaxAbsY(); } double worldHeight = Math.max(1.0, maxAbsY * 2.0); @@ -2154,11 +1741,6 @@ public double getWorldDepth() { return sourceModel.getWorldDepth(xWidth, zWidth, surfScale); } - private void recomputeBaseWorldExtents() { - sourceModel.recomputeBaseWorldExtents(xWidth, zWidth, surfScale); - refreshCoordinateMapper(); - } - private void refreshCoordinateMapper() { coordinateMapper.configure( getSourceWidth(), @@ -2194,29 +1776,7 @@ Double[] getActiveRenderColumn(int column) { /** Recompute world extents after surfScale changes without rebuilding source data. */ private void refreshWorldExtentsAndLodMetadata() { - recomputeBaseWorldExtents(); - - HeightField activeField = sourceModel.getActiveHeightField(); - if (activeField != null) { - sourceModel.setCurrentLodSurfScale( - sourceModel.getBaseWorldWidth() / activeField.width(), - sourceModel.getBaseWorldDepth() / activeField.height()); - } - if (surfPlot != null) { - surfPlot.setTranslateX(-sourceModel.getBaseWorldWidth() / 2.0); - surfPlot.setTranslateZ(-sourceModel.getBaseWorldDepth() / 2.0); - } - 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(); - } + renderController.refreshWorldExtentsAndLodMetadata(); } /** @@ -2262,12 +1822,6 @@ private void setSourceDefaultRowOrientation(SurfaceRowOrientation orientation) { } } - private void applySurfaceRowOrientationToRenderers() { - SurfaceRowOrientation orientation = getSurfaceRowOrientation(); - if (surfPlot != null) surfPlot.setRowOrientation(orientation); - if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setRowOrientation(orientation); - } - public SurfaceHeightOrientation getSurfaceHeightOrientation() { return sourceModel.getHeightOrientation(); } @@ -2280,310 +1834,78 @@ public ObjectProperty surfaceHeightOrientationProperty return sourceModel.heightOrientationProperty(); } - 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) { - return sourceModel.orientRowIndex(logicalRow, rowCount); - } - - private double orientRowCoordinate(double logicalRow, int rowCount) { - return sourceModel.orientRowCoordinate(logicalRow, rowCount); - } - public boolean isTiledHeightFieldRenderingEnabled() { - return tiledHeightFieldRenderingEnabled; + return renderController.isTiledHeightFieldRenderingEnabled(); } public void setTiledHeightFieldRenderingEnabled(boolean enabled) { - if (this.tiledHeightFieldRenderingEnabled == enabled) return; - this.tiledHeightFieldRenderingEnabled = enabled; - if (sourceModel.getLodProcessedLevels() != null && !sourceModel.getLodProcessedLevels().isEmpty()) { - 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(); - } - updateTheMesh(); - } + renderController.setTiledHeightFieldRenderingEnabled(enabled); } public int getTileCellsL0() { - return tileCellsL0; + return renderController.getTileCellsL0(); } - public TiledLodManager.Config getTiledLodConfigCopy() { - return tiledSurfaceRenderer != null - ? tiledSurfaceRenderer.getLodConfigCopy() - : new TiledLodManager.Config(); + return renderController.getTiledLodConfigCopy(); } public void setTiledLodConfig(TiledLodManager.Config config) { - if (tiledSurfaceRenderer == null || config == null) return; - tiledSurfaceRenderer.setLodConfig(config); + renderController.setTiledLodConfig(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); + return renderController.getTiledLodStatistics(); } 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(); - } + renderController.setTileCellsL0(tileCellsL0); } public int getMaxRenderResolution() { - return maxRenderResolution; + return processingController.getMaxRenderResolution(); } 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 (sourceModel.isImageBackedSurface() && sourceModel.getSourceImage() != null && sourceModel.getImageSourceWidth() > 0 && sourceModel.getImageSourceHeight() > 0) { - PixelReader reader = sourceModel.getSourceImage().getPixelReader(); - if (reader != null) { - sourceModel.setProcessingSourceHeightField(buildImageRawL0(reader, - sourceModel.getImageSourceStartX(), sourceModel.getImageSourceStartY(), - sourceModel.getImageSourceWidth(), sourceModel.getImageSourceHeight(), maxRenderResolution)); - } - } + if (!processingController.setMaxRenderResolution(maxRenderResolution)) return; + processingController.rebuildCurrentImageRawL0(); rebuildProcessedGridAndRefresh(); } public int getMinRenderResolution() { - return minRenderResolution; + return processingController.getMinRenderResolution(); } 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; + if (!processingController.setMinRenderResolution(minRenderResolution)) return; rebuildProcessedGridAndRefresh(); } - private boolean shouldUseTiledHeightFieldRenderer() { - return tiledHeightFieldRenderingEnabled - && sourceModel.isImageBackedSurface() - && tiledSurfaceRenderer != null - && sourceModel.getLodProcessedLevels() != null - && !sourceModel.getLodProcessedLevels().isEmpty() - && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; - } - public boolean isTiledHeightFieldRendererActive() { - return shouldUseTiledHeightFieldRenderer() - && surfaceRender - && tiledSurfaceRenderer.isVisible(); + return renderController.isTiledHeightFieldRendererActive(); } private void requestLodUpdate(LodManager.UpdateReason reason) { - if (shouldUseTiledHeightFieldRenderer()) { - tiledSurfaceRenderer.requestUpdate(reason); - } else if (lodManager != null) { - lodManager.requestUpdate(reason); - } + renderController.requestLodUpdate(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 && sourceModel.getSourceImage() != null) { - tiledSurfaceRenderer.setColorByImage(sourceModel.getSourceImage()); - } else if (colorationMethod == COLORATION.COLOR_BY_FEATURE && !sourceModel.getLodProcessedLevels().isEmpty()) { - float[] minMax = sourceModel.getLodProcessedLevels().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; - sourceModel.setActiveLod(0, sourceModel.getLodProcessedLevels().get(0)); - xWidth = sourceModel.getActiveHeightField().width(); - zWidth = sourceModel.getActiveHeightField().height(); - sourceModel.setCurrentLodSurfScale( - sourceModel.getBaseWorldWidth() / xWidth, - sourceModel.getBaseWorldDepth() / zWidth); - refreshCoordinateMapper(); - - tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); - tiledSurfaceRenderer.setPyramid( - sourceModel.getLodProcessedLevels(), sourceModel.getBaseWorldWidth(), sourceModel.getBaseWorldDepth(), yScale); - configureTiledAppearance(); - tiledSurfaceRenderer.forceUpdate(); - syncGuiControls(); + renderController.forceLodUpdate(); } 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 - // sourceModel.getOriginalGrid() and are converted lazily here for compatibility. - if (sourceModel.getProcessingSourceHeightField() == null) { - if (sourceModel.getOriginalGrid() == null || sourceModel.getOriginalGrid().isEmpty()) return; - sourceModel.setProcessingSourceHeightField( - SurfaceUtils.toHeightField(sourceModel.getOriginalGrid())); - } - - HeightField l0Raw = sourceModel.isImageBackedSurface() - ? sourceModel.getProcessingSourceHeightField() - : SurfaceUtils.buildL0Raw(sourceModel.getProcessingSourceHeightField(), 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 - ); - - sourceModel.setLodProcessedLevels(sourceModel.isImageBackedSurface() - ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) - : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution)); - - if (sourceModel.isImageBackedSurface()) { - StringBuilder lodSummary = new StringBuilder("Hypersurface tiled LOD pyramid: "); - for (int i = 0; i < sourceModel.getLodProcessedLevels().size(); i++) { - if (i > 0) lodSummary.append(", "); - HeightField level = sourceModel.getLodProcessedLevels().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); - } - } + if (!processingController.rebuildProcessedPyramid(xWidth, zWidth, surfScale)) return; - // 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. - sourceModel.setActiveLod(-1, null); - sourceModel.clearCurrentLodSurfScale(); - - if (shouldUseTiledHeightFieldRenderer()) { - configureTiledPyramid(); - updateTheMesh(); - } else if (lodManager != null) { - lodManager.setPyramid( - LodManager.fromHeightFields(sourceModel.getLodProcessedLevels()), - sourceModel.getBaseWorldWidth(), - sourceModel.getBaseWorldDepth(), - -1 - ); - lodManager.forceUpdate(); - } else { - applyActiveLodIndex(0); + TiledSurfaceRenderer tiledRenderer = renderController.getTiledSurfaceRenderer(); + if (sourceModel.isImageBackedSurface() + && (tiledRenderer == null || tiledRenderer.isVerboseDiagnosticsEnabled())) { + System.out.println(processingController.buildPyramidSummary()); } + refreshCoordinateMapper(); + renderController.activateProcessedPyramid(); updateView(true); refreshLegendOverlay(); } - /** - * 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 (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); - - // xWidth/zWidth continue to describe the currently rendered mesh dimensions, - // while dataGrid/sourceModel.getOriginalGrid() retain full source/working data. - xWidth = sourceModel.getActiveHeightField().width(); - zWidth = sourceModel.getActiveHeightField().height(); - - sourceModel.setCurrentLodSurfScale( - sourceModel.getBaseWorldWidth() / xWidth, - sourceModel.getBaseWorldDepth() / zWidth); - refreshCoordinateMapper(); - - System.out.println("Applying Hypersurface LOD L" + sourceModel.getActiveLodIndex() - + ": " + xWidth + "x" + zWidth - + ", cellScale=" + sourceModel.getCurrentLodSurfScaleX() + "x" + sourceModel.getCurrentLodSurfScaleZ()); - - if (surfPlot != null) { - surfPlot.setTranslateX(-sourceModel.getBaseWorldWidth() / 2.0); - surfPlot.setTranslateZ(-sourceModel.getBaseWorldDepth() / 2.0); - } - - syncGuiControls(); - updateTheMesh(); - } - - } 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(); + } +} From 32c0f50dd847cb0be4ee65efcbeade18b03a99bd Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Sat, 10 Oct 2026 00:01:03 -0400 Subject: [PATCH 09/10] extracted graph logic to graph controller. --- .../hypersurface/Hypersurface3DPane.java | 167 +------------- .../HypersurfaceGraphController.java | 211 ++++++++++++++++++ 2 files changed, 223 insertions(+), 155 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceGraphController.java 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 index 6f81eca7..f2ba71d0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -4,7 +4,6 @@ import edu.jhuapl.trinity.css.StyleResourceProvider; 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.messages.bci.SemanticMap; import edu.jhuapl.trinity.data.messages.bci.SemanticMapCollection; import edu.jhuapl.trinity.data.messages.bci.SemanticReconstruction; @@ -19,7 +18,6 @@ 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; @@ -27,8 +25,6 @@ import edu.jhuapl.trinity.javafx.events.ManifoldEvent; import edu.jhuapl.trinity.javafx.events.ShadowEvent; import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; -import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; -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; @@ -37,13 +33,11 @@ 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.HeightMode; 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; @@ -60,8 +54,6 @@ import javafx.event.Event; 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; @@ -230,16 +222,9 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private final HypersurfaceRenderController renderController; - // --- 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(); + // 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; @@ -263,6 +248,7 @@ public Hypersurface3DPane(Scene scene) { 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); @@ -302,12 +288,12 @@ public Hypersurface3DPane(Scene scene) { overlayManager.setInitialVisibility(false); sceneRoot.getChildren().addAll(cameraTransform, overlayManager.getHighlightedPoint(), overlayManager.getNodeGroup(), extrasGroup, debugGroup, dataXForm); - // Add graph layer last so it draws above the surface (z-order within Group) - sceneRoot.getChildren().add(graphLayer); + // 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); - graphLayer.setVisible(graphVisible); - // Sync controls with current visibility on startup - fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + graphController.syncVisibilityGui(); subScene.setCamera(camera); pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); @@ -516,61 +502,7 @@ public Hypersurface3DPane(Scene scene) { 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; - interactionController.setGraph(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)); - }); - + graphController.installEventHandlers(); loadSurf3D(); this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { Color color = (Color) e.object; @@ -692,76 +624,6 @@ public void handle(long now) { 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"); @@ -1009,7 +871,7 @@ private void generateRandos(int xWidth, int zWidth, float yScale) { private void configureLightingScopes() { // Keep the camera-following point light focused on the analytical surface/graph. pointLight.getScope().setAll( - surfPlot, renderController.getTiledSurfaceRenderer(), graphLayer); + 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 @@ -1017,7 +879,7 @@ private void configureLightingScopes() { ambientLight.getScope().setAll( surfPlot, renderController.getTiledSurfaceRenderer(), - graphLayer, + graphController.getGraphLayer(), overlayManager.getNodeGroup(), extrasGroup, debugGroup, @@ -1210,11 +1072,6 @@ private void wireEventHandlers() { 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)); - scene.addEventHandler(GraphEvent.GRAPH_VISIBILITY_CHANGED, e -> { - if (!(e.object instanceof Boolean b)) return; - graphVisible = b; - graphLayer.setVisible(graphVisible); - }); } private void attachLegendOverlay(Pane desktopPane) { 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)); + } +} From 94771cee014f1df84c5b1ce9861428cdec8d271a Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Sat, 10 Oct 2026 20:01:30 -0400 Subject: [PATCH 10/10] Rewrote surfacecharts as high speed canvas renders --- .../javafx/components/CanvasLineChart.java | 363 ++++++++++++++++++ .../javafx/components/FactorControlBox.java | 55 +-- .../components/panes/SurfaceChartPane.java | 4 +- .../hypersurface/Hypersurface3DPane.java | 273 ++++++------- .../HypersurfaceInteractionController.java | 1 - .../HypersurfaceOverlayManager.java | 1 - 6 files changed, 494 insertions(+), 203 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java 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/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/javafx3d/hypersurface/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java index f2ba71d0..9e339d30 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -12,7 +12,6 @@ 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.panes.SurfaceChartPane; import edu.jhuapl.trinity.javafx.events.ApplicationEvent; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; @@ -24,7 +23,6 @@ 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.XFormGroup; import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; import edu.jhuapl.trinity.javafx.javafx3d.tasks.DBSCANClusterTask; @@ -40,7 +38,6 @@ import edu.jhuapl.trinity.utils.Utils; 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; @@ -84,7 +81,6 @@ import javafx.scene.paint.Color; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; -import javafx.scene.shape.Shape3D; import javafx.scene.text.Font; import javafx.stage.FileChooser; import javafx.stage.StageStyle; @@ -125,7 +121,6 @@ public class Hypersurface3DPane extends StackPane public PerspectiveCamera camera; public CameraTransformer cameraTransform = new CameraTransformer(); private final HypersurfaceCameraController cameraController; - public XFormGroup dataXForm = new XFormGroup(); private double cameraDistance = -1000; private final double sceneWidth = 4000; @@ -135,12 +130,8 @@ public class Hypersurface3DPane extends StackPane public Group sceneRoot = new Group(); public Group extrasGroup; - 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; @@ -149,10 +140,6 @@ public class Hypersurface3DPane extends StackPane 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} @@ -228,13 +215,7 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public Hypersurface3DPane(Scene scene) { this.scene = scene; - 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()); - }); + configureLegendLifecycle(); ambientLight = new AmbientLight(Color.WHITE); setBackground(Background.EMPTY); @@ -249,8 +230,8 @@ public Hypersurface3DPane(Scene scene) { interactionController = new HypersurfaceInteractionController( this, scene, coordinateMapper, surfaceCrosshairOverlay, overlayManager); graphController = new HypersurfaceGraphController(scene, interactionController::setGraph); - camera = new PerspectiveCamera(true); + camera = new PerspectiveCamera(true); cameraTransform.setTranslate(0, 0, 0); cameraTransform.getChildren().add(camera); camera.setNearClip(0.1); @@ -258,6 +239,7 @@ public Hypersurface3DPane(Scene scene) { camera.setTranslateZ(cameraDistance); cameraTransform.ry.setAngle(-45.0); cameraTransform.rx.setAngle(-10.0); + cameraController = new HypersurfaceCameraController( camera, cameraTransform, @@ -267,6 +249,30 @@ public Hypersurface3DPane(Scene scene) { 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(); @@ -282,12 +288,14 @@ public Hypersurface3DPane(Scene scene) { updateTheMesh(); } }); - cameraController.installInputHandlers(); + } + + private void configureSceneGraph() { setupSkyBox(); - debugGroup.setVisible(false); overlayManager.setInitialVisibility(false); sceneRoot.getChildren().addAll(cameraTransform, overlayManager.getHighlightedPoint(), - overlayManager.getNodeGroup(), extrasGroup, debugGroup, dataXForm); + 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. @@ -295,6 +303,7 @@ public Hypersurface3DPane(Scene scene) { sceneRoot.getChildren().add(surfaceCrosshairOverlay); graphController.syncVisibilityGui(); subScene.setCamera(camera); + pointLight = new PointLight(Color.WHITE); cameraTransform.getChildren().add(pointLight); pointLight.translateXProperty().bind(camera.translateXProperty()); @@ -304,7 +313,9 @@ public Hypersurface3DPane(Scene scene) { 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); @@ -317,16 +328,14 @@ public Hypersurface3DPane(Scene scene) { Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = true; - if (redraw) { - updateView(false); - notifyIndexChange(); - } + notifyIndexChange(); overlayManager.updateLabels(); } } if (keycode == KeyCode.PERIOD) { - int featureSize = featureVectors.isEmpty() ? factorMaxIndex : featureVectors.get(0).getData().size(); + 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) { @@ -336,18 +345,13 @@ public Hypersurface3DPane(Scene scene) { Platform.runLater(() -> scene.getRoot().fireEvent( new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); - boolean redraw = true; - if (redraw) { - updateView(false); - notifyIndexChange(); - } + notifyIndexChange(); overlayManager.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) renderController.scaleHeight(1.1); if (keycode == KeyCode.H) renderController.scaleHeight(0.9); @@ -365,28 +369,27 @@ public Hypersurface3DPane(Scene scene) { requestLodUpdate(LodManager.UpdateReason.OTHER); } }); + } - Pane pathPane = App.getAppPathPaneStack(); - surfaceChartPane = new SurfaceChartPane(scene, pathPane); + 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)); - }); + showControlsItem.setOnAction(e -> scene.getRoot().fireEvent(new ApplicationEvent( + ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE))); - -MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); + 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(); @@ -400,9 +403,11 @@ public Hypersurface3DPane(Scene scene) { 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()); @@ -417,15 +422,15 @@ public Hypersurface3DPane(Scene scene) { 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); + 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(); @@ -434,9 +439,8 @@ public Hypersurface3DPane(Scene scene) { 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); + FeatureCollectionFile fcf = new FeatureCollectionFile(file.getAbsolutePath(), true); computeCosineDistance(fcf.featureCollection); } catch (IOException ex) { LOG.error(null, ex); @@ -448,8 +452,7 @@ public Hypersurface3DPane(Scene scene) { ImageView analysisImageView = ResourceUtils.loadIcon("analysis", ICON_FIT_HEIGHT); analysisImageView.setEffect(glow); Menu analysisMenu = new Menu("Analysis", analysisImageView, - vectorDistanceItem, collectionDifferenceItem, cosineSimilarityItem - ); + vectorDistanceItem, collectionDifferenceItem, cosineSimilarityItem); CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); enableHoverItem.setOnAction(e -> @@ -465,6 +468,7 @@ public Hypersurface3DPane(Scene scene) { MenuItem updateAllItem = new MenuItem("Update Render"); updateAllItem.setOnAction(e -> updateAll()); + MenuItem clearDataItem = new MenuItem("Clear Data"); clearDataItem.setOnAction(e -> { clearAll(); @@ -486,6 +490,7 @@ public Hypersurface3DPane(Scene scene) { 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, @@ -502,56 +507,23 @@ public Hypersurface3DPane(Scene scene) { e.consume(); } }); - graphController.installEventHandlers(); - loadSurf3D(); - this.scene.addEventHandler(HyperspaceEvent.HYPERSPACE_BACKGROUND_COLOR, e -> { + } + + private void registerSceneEventHandlers() { + 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); - String DIALOGCSS = StyleResourceProvider.getResource("dialogstyles.css").toExternalForm(); - dialogPane.getStylesheets().add(DIALOGCSS); - 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); - sourceModel.setSourceImage(image); - }); - this.scene.addEventHandler(HyperspaceEvent.FACTOR_COORDINATES_GUI, e -> { + 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(); - updateView(true); notifyIndexChange(); }); @@ -572,11 +544,10 @@ public Hypersurface3DPane(Scene scene) { zFactorIndex = factorMaxIndex; update = true; } - if (update) { - updateView(true); - notifyIndexChange(); - } - } else factorMaxIndex = newFactorMaxIndex; + if (update) notifyIndexChange(); + } else { + factorMaxIndex = newFactorMaxIndex; + } }); scene.addEventHandler(HypersurfaceGridEvent.RENDER_PDF, e -> { @@ -588,40 +559,47 @@ public Hypersurface3DPane(Scene scene) { 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 -> overlayManager.setAxesAndLabelsVisible((boolean) e.object)); - scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, e -> imageryBasePath = (String) e.object); + scene.addEventHandler(ApplicationEvent.SET_IMAGERY_BASEPATH, + e -> imageryBasePath = (String) e.object); + Platform.runLater(() -> { overlayManager.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)); - } + } + + 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; } - }; - surfUpdateAnimationTimer.start(); + } + tessellateImage(image, x1, y1, x2, y2); } public void computeCosineDistance(FeatureCollection collection) { @@ -714,22 +692,6 @@ public void unrollHyperspace() { getScene().getRoot().fireEvent(new FeatureVectorEvent(FeatureVectorEvent.REQUEST_FEATURE_COLLECTION)); } - public void updateCalloutHeadPoint(Shape3D node, Callout callout, SubScene subScene) { - overlayManager.updateCalloutHeadPoint(node, callout, subScene); - } - - public void updateCalloutHeadPoints(SubScene subScene) { - overlayManager.updateCalloutHeadPoints(subScene); - } - - public Callout createCallout(Shape3D shape3D, FeatureVector featureVector, SubScene subScene) { - return overlayManager.createCallout(shape3D, featureVector, subScene); - } - - public void addCallout(Callout callout, Shape3D shape3D) { - overlayManager.addCallout(callout, shape3D); - } - public void updateTheMesh() { renderController.updateMesh(); } @@ -787,7 +749,11 @@ public void outtro(double milliseconds) { } public void updateAll() { - Platform.runLater(() -> updateView(true)); + if (Platform.isFxApplicationThread()) { + updateTheMesh(); + } else { + Platform.runLater(this::updateTheMesh); + } } private void handleCameraInteraction(HypersurfaceCameraController.InteractionType interactionType) { @@ -847,17 +813,6 @@ void showSurfaceChartsPane() { } } - 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) { dataGrid.clear(); List xList; @@ -882,7 +837,6 @@ private void configureLightingScopes() { graphController.getGraphLayer(), overlayManager.getNodeGroup(), extrasGroup, - debugGroup, overlayManager.getHighlightedPoint(), surfaceCrosshairOverlay); } @@ -1182,10 +1136,6 @@ public BooleanProperty legendEnabledProperty() { return legendEnabled; } - public void updateCalloutByFeatureVector(Callout callout, FeatureVector featureVector) { - overlayManager.updateCalloutByFeatureVector(callout, featureVector); - } - public void clearAll() { xFactorIndex = 0; yFactorIndex = 1; @@ -1194,7 +1144,6 @@ public void clearAll() { new HyperspaceEvent(HyperspaceEvent.FACTOR_COORDINATES_KEYPRESS, new CoordinateSet(xFactorIndex, yFactorIndex, zFactorIndex)))); notifyIndexChange(); - ellipsoidGroup.getChildren().clear(); overlayManager.resetLabelTracking(); dataGrid.clear(); featureVectors.clear(); @@ -1204,7 +1153,7 @@ public void clearAll() { } public void showAll() { - updateView(true); + updateAll(); } public void hideFA3D() { @@ -1419,7 +1368,7 @@ public void setDimensionLabels(List labelStrings) { @Override public void setSpheroidAnchor(boolean animate, int index) { - double z = index * surfScale; + // Hypersurface does not use spheroid anchoring. } private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { @@ -1487,7 +1436,6 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { int x2 = (int) wi.getWidth(); int y2 = (int) wi.getHeight(); tessellateImage(wi, 0, 0, x2, y2); - sourceModel.setSourceImage(wi); LOG.info("injecting Shapley function values into Vertices... "); long startTime = System.nanoTime(); renderController.updateShapleyFunctionValues(shapleyVectors, yScale); @@ -1761,7 +1709,6 @@ private void rebuildProcessedGridAndRefresh() { refreshCoordinateMapper(); renderController.activateProcessedPyramid(); - updateView(true); refreshLegendOverlay(); } 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 index fccb0e05..13328bf6 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -318,7 +318,6 @@ private void publishFeatureSelection(int sourceRow) { FeatureVector featureVector = featureVectors.get(sourceRow); if (featureVector == null || featureVector == lastPublishedFeatureVector) return; lastPublishedFeatureVector = featureVector; - pane.setSpheroidAnchor(false, sourceRow); publishFeatureVector(featureVector, false); } 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 index 48047946..448d0e66 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceOverlayManager.java @@ -149,7 +149,6 @@ private void wireSelectionHandlers() { int maxSourceRow = Math.max(0, owner.getSourceHeight() - 1); anchorIndex = Math.max(0, Math.min(anchorIndex, maxSourceRow)); glowLineBox.setTranslateZ(coordinateMapper.sourceRowToWorldZ(anchorIndex)); - owner.setSpheroidAnchor(true, anchorIndex); updateTimelineLabels(); refreshProjectedOverlays(); });