diff --git a/src/main/java/edu/jhuapl/trinity/data/messages/xai/TextEmbeddingCollection.java b/src/main/java/edu/jhuapl/trinity/data/messages/xai/TextEmbeddingCollection.java index dd0d2a57..4723434b 100644 --- a/src/main/java/edu/jhuapl/trinity/data/messages/xai/TextEmbeddingCollection.java +++ b/src/main/java/edu/jhuapl/trinity/data/messages/xai/TextEmbeddingCollection.java @@ -18,7 +18,7 @@ public class TextEmbeddingCollection extends MessageData { // /* { "type": "TextEmbeddingCollection", - "label" : "chatGPT", + "label" : "embeddingModelSource", "score" : 0.5, "text_embeddings": [ ...boat load of TextEmbeddingSet objects 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 9f1345a2..10c53be0 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,8 +6,13 @@ import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.SurfaceHeightOrientation; +import edu.jhuapl.trinity.javafx.javafx3d.SurfaceRowOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; +import edu.jhuapl.trinity.javafx.javafx3d.TiledLodManager; +import edu.jhuapl.trinity.javafx.javafx3d.TiledSurfaceRenderer; import edu.jhuapl.trinity.utils.DataUtils.HeightMode; +import javafx.animation.AnimationTimer; import javafx.event.Event; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -16,6 +21,7 @@ import javafx.scene.control.ColorPicker; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; import javafx.scene.control.Separator; import javafx.scene.control.Spinner; import javafx.scene.control.SpinnerValueFactory; @@ -38,6 +44,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private static final int PANEL_WIDTH = 400; private static final int PANEL_HEIGHT = 640; + private static final int TAB_CONTENT_HEIGHT = PANEL_HEIGHT - 96; public static double SPINNER_PREF_WIDTH = 125.0; public static double COMBO_PREF_WIDTH = 220.0; @@ -57,6 +64,8 @@ public class HypersurfaceControlsPane extends LitPathPane { private ComboBox drawModeCombo; private ComboBox cullFaceCombo; private ComboBox colorationCombo; + private ComboBox rowOrientationCombo; + private ComboBox heightOrientationCombo; // Processing private ComboBox heightModeCombo; @@ -80,6 +89,37 @@ public class HypersurfaceControlsPane extends LitPathPane { // Graph overlay visibility private ToggleButton showGraphToggle; + // Tiled LOD controls + private ComboBox tileSizeCombo; + private ComboBox maxDetailCombo; + private Spinner targetPixelsSpinner; + private Spinner coarsenThresholdSpinner; + private Spinner refineThresholdSpinner; + private Spinner throttleMsSpinner; + private Spinner settleMsSpinner; + private Spinner initialSettleMsSpinner; + private Spinner activeTransitionsSpinner; + private Spinner settledTransitionsSpinner; + private Spinner activeBuildsSpinner; + private Spinner settledBuildsSpinner; + private CheckBox geometryBudgetCheck; + private CheckBox verboseLodDiagnosticsCheck; + private Spinner triangleBudgetSpinner; + + private Label lodStatusLabel; + private Label visibleTilesLabel; + private Label lod0CountLabel; + private Label lod1CountLabel; + private Label lod2CountLabel; + private Label lod3CountLabel; + private Label lod4CountLabel; + private Label triangleCountLabel; + private Label rawRequestedTrianglesLabel; + private Label budgetTargetTrianglesLabel; + private Label budgetCoarsenedLabel; + private Label pendingTransitionsLabel; + private AnimationTimer lodDiagnosticsTimer; + public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane target) { super(scene, parent, PANEL_WIDTH, PANEL_HEIGHT, new BorderPane(), "Hypersurface Controls", "", 200.0, 300.0); this.scene = scene; @@ -126,6 +166,8 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar } e.consume(); }); + + startLodDiagnosticsTimer(); } private TabPane buildTabs() { @@ -177,13 +219,13 @@ private TabPane buildTabs() { drawModeCombo = new ComboBox<>(); drawModeCombo.getItems().addAll(DrawMode.LINE, DrawMode.FILL); - drawModeCombo.getSelectionModel().select(DrawMode.LINE); + drawModeCombo.getSelectionModel().select(DrawMode.FILL); styleCombo(drawModeCombo); addRow(renderGrid, 1, "Draw", drawModeCombo); cullFaceCombo = new ComboBox<>(); cullFaceCombo.getItems().addAll(CullFace.FRONT, CullFace.BACK, CullFace.NONE); - cullFaceCombo.getSelectionModel().select(CullFace.NONE); + cullFaceCombo.getSelectionModel().select(CullFace.BACK); styleCombo(cullFaceCombo); addRow(renderGrid, 2, "Cull", cullFaceCombo); @@ -197,6 +239,22 @@ private TabPane buildTabs() { styleCombo(colorationCombo); addRow(renderGrid, 3, "Color", colorationCombo); + rowOrientationCombo = new ComboBox<>(); + rowOrientationCombo.getItems().addAll(SurfaceRowOrientation.values()); + rowOrientationCombo.getSelectionModel().select(target != null + ? target.getSurfaceRowOrientation() + : SurfaceRowOrientation.FIRST_ROW_NEAR); + styleCombo(rowOrientationCombo); + addRow(renderGrid, 4, "Row orientation", rowOrientationCombo); + + heightOrientationCombo = new ComboBox<>(); + heightOrientationCombo.getItems().addAll(SurfaceHeightOrientation.values()); + heightOrientationCombo.getSelectionModel().select(target != null + ? target.getSurfaceHeightOrientation() + : SurfaceHeightOrientation.HIGH_VALUES_UP); + styleCombo(heightOrientationCombo); + addRow(renderGrid, 5, "Height orientation", heightOrientationCombo); + meshTypeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.surfaceRender("Surface".equals(meshTypeCombo.getValue())))); drawModeCombo.setOnAction(e -> @@ -205,6 +263,28 @@ private TabPane buildTabs() { fireOnRoot(HypersurfaceEvent.cullFace(cullFaceCombo.getValue()))); colorationCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.coloration(colorationCombo.getValue()))); + rowOrientationCombo.setOnAction(e -> { + if (target != null && rowOrientationCombo.getValue() != null) { + target.setSurfaceRowOrientation(rowOrientationCombo.getValue()); + } + }); + heightOrientationCombo.setOnAction(e -> { + if (target != null && heightOrientationCombo.getValue() != null) { + target.setSurfaceHeightOrientation(heightOrientationCombo.getValue()); + } + }); + if (target != null) { + target.surfaceRowOrientationProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null && rowOrientationCombo.getValue() != newValue) { + rowOrientationCombo.getSelectionModel().select(newValue); + } + }); + target.surfaceHeightOrientationProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null && heightOrientationCombo.getValue() != newValue) { + heightOrientationCombo.getSelectionModel().select(newValue); + } + }); + } // === Scene / Lighting === GridPane sceneGrid = formGrid(); @@ -351,23 +431,279 @@ private TabPane buildTabs() { ); procTabContent.setPadding(new Insets(6)); + // === LOD tab === + TiledLodManager.Config lodConfig = target != null + ? target.getTiledLodConfigCopy() + : new TiledLodManager.Config(); + + GridPane tileGrid = formGrid(); + tileSizeCombo = new ComboBox<>(); + tileSizeCombo.getItems().addAll(128, 256, 512); + int tileSize0 = target != null ? target.getTileCellsL0() : 256; + if (!tileSizeCombo.getItems().contains(tileSize0)) { + tileSizeCombo.getItems().add(tileSize0); + } + tileSizeCombo.getSelectionModel().select(Integer.valueOf(tileSize0)); + styleCombo(tileSizeCombo); + + maxDetailCombo = new ComboBox<>(); + maxDetailCombo.getItems().addAll("L0", "L1", "L2", "L3", "L4"); + int finest0 = Math.max(0, Math.min(4, lodConfig.finestAllowedLod)); + maxDetailCombo.getSelectionModel().select(finest0); + styleCombo(maxDetailCombo); + + addRow(tileGrid, 0, "Tile size", tileSizeCombo); + addRow(tileGrid, 1, "Maximum detail", maxDetailCombo); + + GridPane selectionGrid = formGrid(); + targetPixelsSpinner = new Spinner<>(0.25, 5.0, + clampDouble(lodConfig.targetPixelsPerCell, 0.25, 5.0), 0.10); + coarsenThresholdSpinner = new Spinner<>(0.25, 5.0, + clampDouble(lodConfig.lowThreshold, 0.25, 5.0), 0.10); + refineThresholdSpinner = new Spinner<>(0.25, 5.0, + clampDouble(lodConfig.highThreshold, 0.25, 5.0), 0.10); + styleSpinner(targetPixelsSpinner); + styleSpinner(coarsenThresholdSpinner); + styleSpinner(refineThresholdSpinner); + targetPixelsSpinner.setEditable(true); + coarsenThresholdSpinner.setEditable(true); + refineThresholdSpinner.setEditable(true); + + addRow(selectionGrid, 0, "Target px/cell", targetPixelsSpinner); + addRow(selectionGrid, 1, "Coarsen below", coarsenThresholdSpinner); + addRow(selectionGrid, 2, "Refine above", refineThresholdSpinner); + + GridPane budgetGrid = formGrid(); + geometryBudgetCheck = new CheckBox(); + geometryBudgetCheck.setSelected(lodConfig.geometryBudgetEnabled); + triangleBudgetSpinner = new Spinner<>(0.25, 50.0, + clampDouble(lodConfig.triangleBudget / 1_000_000.0, 0.25, 50.0), 0.25); + styleSpinner(triangleBudgetSpinner); + triangleBudgetSpinner.setEditable(true); + addRow(budgetGrid, 0, "Enable budget", geometryBudgetCheck); + addRow(budgetGrid, 1, "Triangle budget (M)", triangleBudgetSpinner); + + GridPane timingGrid = formGrid(); + throttleMsSpinner = new Spinner<>(0, 1000, clampInt(lodConfig.throttleMs, 0, 1000), 5); + settleMsSpinner = new Spinner<>(0, 2000, clampInt(lodConfig.debounceMs, 0, 2000), 10); + initialSettleMsSpinner = new Spinner<>(0, 3000, + clampInt(lodConfig.initialSettleMs, 0, 3000), 25); + activeTransitionsSpinner = new Spinner<>(1, 32, + clampInt(lodConfig.activeTransitionsPerPulse, 1, 32), 1); + settledTransitionsSpinner = new Spinner<>(1, 64, + clampInt(lodConfig.settledTransitionsPerPulse, 1, 64), 1); + activeBuildsSpinner = new Spinner<>(0, 8, + clampInt(lodConfig.activeBuildsPerPulse, 0, 8), 1); + settledBuildsSpinner = new Spinner<>(1, 16, + clampInt(lodConfig.settledBuildsPerPulse, 1, 16), 1); + + styleSpinner(throttleMsSpinner); + styleSpinner(settleMsSpinner); + styleSpinner(initialSettleMsSpinner); + styleSpinner(activeTransitionsSpinner); + styleSpinner(settledTransitionsSpinner); + styleSpinner(activeBuildsSpinner); + styleSpinner(settledBuildsSpinner); + + throttleMsSpinner.setEditable(true); + settleMsSpinner.setEditable(true); + initialSettleMsSpinner.setEditable(true); + activeTransitionsSpinner.setEditable(true); + settledTransitionsSpinner.setEditable(true); + activeBuildsSpinner.setEditable(true); + settledBuildsSpinner.setEditable(true); + + addRow(timingGrid, 0, "Update throttle", throttleMsSpinner); + addRow(timingGrid, 1, "Camera settle", settleMsSpinner); + addRow(timingGrid, 2, "Initial settle", initialSettleMsSpinner); + addRow(timingGrid, 3, "Active transitions", activeTransitionsSpinner); + addRow(timingGrid, 4, "Settled transitions", settledTransitionsSpinner); + addRow(timingGrid, 5, "Active builds", activeBuildsSpinner); + addRow(timingGrid, 6, "Settled builds", settledBuildsSpinner); + + GridPane diagnosticsGrid = formGrid(); + lodStatusLabel = new Label("Inactive"); + visibleTilesLabel = new Label("0 / 0"); + lod0CountLabel = new Label("0"); + lod1CountLabel = new Label("0"); + lod2CountLabel = new Label("0"); + lod3CountLabel = new Label("0"); + lod4CountLabel = new Label("0"); + triangleCountLabel = new Label("0"); + rawRequestedTrianglesLabel = new Label("0"); + budgetTargetTrianglesLabel = new Label("0"); + budgetCoarsenedLabel = new Label("0"); + pendingTransitionsLabel = new Label("0"); + verboseLodDiagnosticsCheck = new CheckBox(); + verboseLodDiagnosticsCheck.setSelected(lodConfig.verboseDiagnostics); + + addRow(diagnosticsGrid, 0, "Renderer", lodStatusLabel); + addRow(diagnosticsGrid, 1, "Visible tiles", visibleTilesLabel); + addRow(diagnosticsGrid, 2, "L0 tiles", lod0CountLabel); + addRow(diagnosticsGrid, 3, "L1 tiles", lod1CountLabel); + addRow(diagnosticsGrid, 4, "L2 tiles", lod2CountLabel); + addRow(diagnosticsGrid, 5, "L3 tiles", lod3CountLabel); + addRow(diagnosticsGrid, 6, "L4 tiles", lod4CountLabel); + addRow(diagnosticsGrid, 7, "Current triangles", triangleCountLabel); + addRow(diagnosticsGrid, 8, "Raw requested", rawRequestedTrianglesLabel); + addRow(diagnosticsGrid, 9, "Budget target", budgetTargetTrianglesLabel); + addRow(diagnosticsGrid, 10, "Budget-coarsened", budgetCoarsenedLabel); + addRow(diagnosticsGrid, 11, "Pending", pendingTransitionsLabel); + addRow(diagnosticsGrid, 12, "Verbose console", verboseLodDiagnosticsCheck); + + tileSizeCombo.setOnAction(e -> { + if (target != null && tileSizeCombo.getValue() != null) { + target.setTileCellsL0(tileSizeCombo.getValue()); + } + }); + maxDetailCombo.setOnAction(e -> applyLodConfigFromControls()); + targetPixelsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + coarsenThresholdSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + refineThresholdSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + geometryBudgetCheck.setOnAction(e -> applyLodConfigFromControls()); + triangleBudgetSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + throttleMsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + settleMsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + initialSettleMsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + activeTransitionsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + settledTransitionsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + activeBuildsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + settledBuildsSpinner.valueProperty().addListener((o, ov, nv) -> applyLodConfigFromControls()); + verboseLodDiagnosticsCheck.setOnAction(e -> applyLodConfigFromControls()); + + VBox lodTabContent = new VBox(10, + titledBox("Tile Layout", tileGrid), + titledBox("LOD Selection", selectionGrid), + titledBox("Geometry Budget", budgetGrid), + titledBox("Scheduling", timingGrid), + titledBox("Live Diagnostics", diagnosticsGrid) + ); + lodTabContent.setPadding(new Insets(6)); + + ScrollPane lodScrollPane = new ScrollPane(lodTabContent); + lodScrollPane.setFitToWidth(true); + lodScrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + lodScrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + // Do not let the tall LOD form propagate its preferred height into + // TabPane -> BorderPane -> LitPathPane. The LOD content scrolls + // inside the fixed-height controls pane instead. + lodScrollPane.setMinHeight(0.0); + lodScrollPane.setPrefViewportHeight(TAB_CONTENT_HEIGHT); + lodScrollPane.setPrefHeight(TAB_CONTENT_HEIGHT); + lodScrollPane.setMaxHeight(Double.MAX_VALUE); + // === TabPane === TabPane tabs = new TabPane(); tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); tabs.setPrefWidth(PANEL_WIDTH - 8); + // Keep the tab control bounded by the LitPathPane's intended default + // height. Individual tabs may scroll internally rather than forcing + // the floating pane to grow to their preferred content height. + tabs.setMinHeight(0.0); + tabs.setPrefHeight(TAB_CONTENT_HEIGHT); + tabs.setMaxHeight(Double.MAX_VALUE); GraphControlsView graphLayoutView = new GraphControlsView(scene); GraphStyleControlsView graphStyleView = new GraphStyleControlsView(scene); Tab t1 = new Tab("View", viewTabContent); Tab t2 = new Tab("Processing", procTabContent); - Tab t3 = new Tab("Graph Layout", graphLayoutView); - Tab t4 = new Tab("Graph Style", graphStyleView); + Tab t3 = new Tab("LOD", lodScrollPane); + Tab t4 = new Tab("Graph Layout", graphLayoutView); + Tab t5 = new Tab("Graph Style", graphStyleView); - tabs.getTabs().addAll(t1, t2, t3, t4); + tabs.getTabs().addAll(t1, t2, t3, t4, t5); return tabs; } + private void applyLodConfigFromControls() { + if (target == null + || targetPixelsSpinner == null + || coarsenThresholdSpinner == null + || refineThresholdSpinner == null) { + return; + } + + double low = coarsenThresholdSpinner.getValue(); + double high = refineThresholdSpinner.getValue(); + if (!(low > 0.0) || !(high > low)) { + return; + } + + TiledLodManager.Config config = target.getTiledLodConfigCopy(); + config.targetPixelsPerCell = targetPixelsSpinner.getValue(); + config.lowThreshold = low; + config.highThreshold = high; + config.finestAllowedLod = maxDetailCombo != null + ? Math.max(0, maxDetailCombo.getSelectionModel().getSelectedIndex()) + : 0; + config.geometryBudgetEnabled = geometryBudgetCheck != null && geometryBudgetCheck.isSelected(); + config.triangleBudget = triangleBudgetSpinner != null + ? Math.max(1L, Math.round(triangleBudgetSpinner.getValue() * 1_000_000.0)) + : 5_000_000L; + config.verboseDiagnostics = verboseLodDiagnosticsCheck != null + && verboseLodDiagnosticsCheck.isSelected(); + config.throttleMs = throttleMsSpinner.getValue(); + config.debounceMs = settleMsSpinner.getValue(); + config.initialSettleMs = initialSettleMsSpinner.getValue(); + config.activeTransitionsPerPulse = activeTransitionsSpinner.getValue(); + config.settledTransitionsPerPulse = settledTransitionsSpinner.getValue(); + config.activeBuildsPerPulse = activeBuildsSpinner.getValue(); + config.settledBuildsPerPulse = settledBuildsSpinner.getValue(); + + target.setTiledLodConfig(config); + } + + private void startLodDiagnosticsTimer() { + if (lodDiagnosticsTimer != null) return; + lodDiagnosticsTimer = new AnimationTimer() { + private long lastUpdateNanos; + + @Override + public void handle(long now) { + if (now - lastUpdateNanos < 250_000_000L) return; + lastUpdateNanos = now; + refreshLodDiagnostics(); + } + }; + lodDiagnosticsTimer.start(); + } + + private void refreshLodDiagnostics() { + if (target == null || lodStatusLabel == null) return; + TiledSurfaceRenderer.LodStatistics stats = target.getTiledLodStatistics(); + lodStatusLabel.setText(target.isTiledHeightFieldRendererActive() ? "Active" : "Inactive"); + visibleTilesLabel.setText(stats.visibleTiles() + " / " + stats.totalTiles()); + lod0CountLabel.setText(Integer.toString(stats.lod0Tiles())); + lod1CountLabel.setText(Integer.toString(stats.lod1Tiles())); + lod2CountLabel.setText(Integer.toString(stats.lod2Tiles())); + lod3CountLabel.setText(Integer.toString(stats.lod3Tiles())); + lod4CountLabel.setText(Integer.toString(stats.lod4Tiles())); + triangleCountLabel.setText(formatTriangleCount(stats.visibleTriangles())); + rawRequestedTrianglesLabel.setText(formatTriangleCount(stats.rawRequestedTriangles())); + budgetTargetTrianglesLabel.setText(formatTriangleCount(stats.budgetedTargetTriangles())); + budgetCoarsenedLabel.setText(Integer.toString(stats.budgetCoarsenedTiles())); + pendingTransitionsLabel.setText(Integer.toString(stats.pendingTransitions())); + } + + private static String formatTriangleCount(long triangles) { + if (triangles >= 1_000_000L) { + return String.format("%.2f M", triangles / 1_000_000.0); + } + if (triangles >= 1_000L) { + return String.format("%.1f K", triangles / 1_000.0); + } + return Long.toString(triangles); + } + + private static int clampInt(long value, int min, int max) { + return (int) Math.max(min, Math.min(max, value)); + } + + private static double clampDouble(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + private void fireOnRoot(Event evt) { if (scene != null && scene.getRoot() != null) { scene.getRoot().fireEvent(evt); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java new file mode 100644 index 00000000..5df03103 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java @@ -0,0 +1,126 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Lightweight primitive height field container (row-major float array). + * + *

Indexing: (x,y) where x in [0,width), y in [0,height) and + * linear index = y * width + x.

+ */ +public final class HeightField { + + private final int width; + private final int height; + private final float[] heights; // row-major, length = width * height + + /** + * Creates a new HeightField that wraps the provided array. + * The array is NOT copied. + * + * @param width width in samples (x dimension), must be > 0 + * @param height height in samples (y dimension), must be > 0 + * @param heights row-major float array of length width*height + */ + public HeightField(int width, int height, float[] heights) { + if (width <= 0) throw new IllegalArgumentException("width must be > 0"); + if (height <= 0) throw new IllegalArgumentException("height must be > 0"); + Objects.requireNonNull(heights, "heights"); + int expected = Math.multiplyExact(width, height); + if (heights.length != expected) { + throw new IllegalArgumentException( + "heights length mismatch: expected " + expected + " but got " + heights.length); + } + this.width = width; + this.height = height; + this.heights = heights; + } + + /** + * Allocates a new HeightField with zero-filled heights. + */ + public static HeightField allocate(int width, int height) { + int len = Math.multiplyExact(width, height); + return new HeightField(width, height, new float[len]); + } + + /** + * Creates a new HeightField by copying the provided array. + */ + public static HeightField copyOf(int width, int height, float[] heights) { + Objects.requireNonNull(heights, "heights"); + return new HeightField(width, height, Arrays.copyOf(heights, heights.length)); + } + + public int width() { + return width; + } + + public int height() { + return height; + } + + /** + * Direct access to the underlying row-major array. + * This is intentionally exposed for performance-sensitive operations. + */ + public float[] data() { + return heights; + } + + public int size() { + return heights.length; + } + + public float get(int x, int y) { + checkBounds(x, y); + return heights[indexOf(x, y)]; + } + + public void set(int x, int y, float value) { + checkBounds(x, y); + heights[indexOf(x, y)] = value; + } + + public int indexOf(int x, int y) { + // no bounds check here; callers can use checkBounds if needed + return y * width + x; + } + + public void fill(float value) { + Arrays.fill(heights, value); + } + + /** + * Returns a deep copy of this HeightField. + */ + public HeightField copy() { + return HeightField.copyOf(width, height, heights); + } + + /** + * Computes {min, max} across the height data. + * @return float[2] where [0]=min, [1]=max. If empty (should not happen), returns {0,0}. + */ + public float[] minMax() { + if (heights.length == 0) return new float[]{0f, 0f}; + float min = Float.POSITIVE_INFINITY; + float max = Float.NEGATIVE_INFINITY; + for (float v : heights) { + if (v < min) min = v; + if (v > max) max = v; + } + return new float[]{min, max}; + } + + private void checkBounds(int x, int y) { + if (x < 0 || x >= width) throw new IndexOutOfBoundsException("x out of range: " + x); + if (y < 0 || y >= height) throw new IndexOutOfBoundsException("y out of range: " + y); + } + + @Override + public String toString() { + return "HeightField{width=" + width + ", height=" + height + ", size=" + heights.length + "}"; + } +} \ No newline at end of file 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 79856ea3..913092ba 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -39,6 +39,11 @@ import javafx.collections.ObservableFloatArray; 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; import javafx.scene.shape.CullFace; import javafx.scene.shape.DrawMode; import javafx.scene.shape.TriangleMesh; @@ -50,6 +55,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; /** @@ -63,11 +70,34 @@ public class HyperSurfacePlotMesh extends TexturedMesh { private static final double DEFAULT_Y_RANGE = 10; // -5 +5 private static final int DEFAULT_X_DIVISIONS = 64; private static final int DEFAULT_Y_DIVISIONS = 64; + + /** Shared immutable connectivity templates for direct HeightField meshes. */ + private static final Map HEIGHT_FIELD_TOPOLOGY_CACHE = new ConcurrentHashMap<>(); + + private record MeshTopology(int[] faces, int[] smoothingGroups) { } private static final double DEFAULT_FUNCTION_SCALE = 1.0D; private static final double DEFAULT_SURF_SCALE = 1.0D; public List functionValues; private PolygonMesh polygonMesh; + // Direct primitive HeightField mesh state. The legacy FXyz Point3D/Face3 lists + // intentionally remain empty while this mode is active. + private boolean directHeightFieldMesh = false; + private HeightField directHeightField; + private int directStartX; + private int directStartZ; + private int directCellsX; + private int directCellsZ; + private int directVertsX; + private int directVertsZ; + private double directXScale; + private double directYScale; + private double directZScale; + private WritableImage directPaletteImage; + private int directPaletteColors = -1; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + private SurfaceHeightOrientation heightOrientation = SurfaceHeightOrientation.HIGH_VALUES_UP; + public HyperSurfacePlotMesh() { this(DEFAULT_FUNCTION, DEFAULT_X_RANGE, DEFAULT_Y_RANGE, DEFAULT_X_DIVISIONS, DEFAULT_Y_DIVISIONS, DEFAULT_FUNCTION_SCALE); } @@ -116,12 +146,52 @@ public HyperSurfacePlotMesh(int rangeX, int rangeY, int divisionsX, int division setDepthTest(DepthTest.ENABLE); } + public SurfaceRowOrientation getRowOrientation() { + return rowOrientation; + } + + /** + * Sets how source rows map to increasing world Z. Geometry is rebuilt by the + * owning Hypersurface/renderer after this value changes. + */ + public void setRowOrientation(SurfaceRowOrientation rowOrientation) { + this.rowOrientation = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + } + + public SurfaceHeightOrientation getHeightOrientation() { + return heightOrientation; + } + + /** + * Sets how scalar height values map to the JavaFX Y axis. Geometry is rebuilt + * by the owning Hypersurface/renderer after this value changes. + */ + public void setHeightOrientation(SurfaceHeightOrientation heightOrientation) { + this.heightOrientation = heightOrientation != null + ? heightOrientation + : SurfaceHeightOrientation.HIGH_VALUES_UP; + } + public javafx.geometry.Point3D getPoint3DByVertNumber(int pointId) { + if (directHeightFieldMesh && mesh != null) { + int base = Math.multiplyExact(pointId, 3); + if (base < 0 || base + 2 >= mesh.getPoints().size()) { + throw new IndexOutOfBoundsException("pointId out of range: " + pointId); + } + return new javafx.geometry.Point3D( + mesh.getPoints().get(base), + mesh.getPoints().get(base + 1), + mesh.getPoints().get(base + 2) + ); + } Point3D p = listVertices.get(pointId); return new javafx.geometry.Point3D(p.x, p.y, p.z); } public final void injectMesh(TriangleMesh newMesh) { + clearDirectHeightFieldState(); setMesh(null); mesh = newMesh; setMesh(mesh); @@ -129,12 +199,168 @@ public final void injectMesh(TriangleMesh newMesh) { public final void updateMeshRaw(int rangeX, int rangeY, double xScale, double yScale, double zScale) { + clearDirectHeightFieldState(); setMesh(null); mesh = createRawMesh(getFunctionVert3D(), rangeX, rangeY, xScale, yScale, zScale); setMesh(mesh); } + /** + * Builds the entire HeightField directly into JavaFX primitive mesh buffers. + * This avoids the legacy per-vertex Point3D and per-face Face3 object graph. + * + *

The cell-oriented convention intentionally matches the existing Hypersurface: + * a width x height HeightField produces (width + 1) x (height + 1) vertices. The + * outermost row/column repeats the last source sample, preserving the existing + * width*xScale and height*zScale world extents without a zero-height border.

+ */ + public final void updateMeshHeightField(HeightField heightField, + double xScale, double yScale, double zScale) { + updateMeshHeightField(heightField, 0, 0, + heightField.width(), heightField.height(), + xScale, yScale, zScale); + } + + /** + * Builds a rectangular cell region directly from a HeightField. This overload is + * intentionally tile-ready: future tile MeshViews can request independent regions + * without changing the primitive mesh builder. + * + * @param heightField source primitive height field + * @param startX first source cell/sample x index + * @param startZ first source cell/sample z index + * @param cellsX number of mesh cells in x (vertices = cellsX + 1) + * @param cellsZ number of mesh cells in z (vertices = cellsZ + 1) + * @param xScale world units per x cell + * @param yScale height multiplier + * @param zScale world units per z cell + */ + public final void updateMeshHeightField(HeightField heightField, + int startX, int startZ, + int cellsX, int cellsZ, + double xScale, double yScale, double zScale) { + validateHeightFieldRegion(heightField, startX, startZ, cellsX, cellsZ); + + directHeightFieldMesh = true; + directHeightField = heightField; + directStartX = startX; + directStartZ = startZ; + directCellsX = cellsX; + directCellsZ = cellsZ; + directVertsX = cellsX + 1; + directVertsZ = cellsZ + 1; + directXScale = xScale; + directYScale = yScale; + directZScale = zScale; + + // Release any object-heavy legacy geometry retained by TexturedMesh. + listVertices.clear(); + listTextures.clear(); + listFaces.clear(); + smoothingGroups = null; + + setMesh(null); + mesh = createHeightFieldMesh(heightField, startX, startZ, cellsX, cellsZ, + xScale, yScale, zScale); + setMesh(mesh); + } + + /** + * Installs a previously built direct HeightField TriangleMesh and restores the + * source-region metadata required by direct image/height coloration. No geometry + * buffers are rebuilt. This is used by the tiled renderer's per-LOD mesh cache. + */ + public final void installCachedHeightFieldMesh(TriangleMesh cachedMesh, + HeightField heightField, + int startX, int startZ, + int cellsX, int cellsZ, + double xScale, double yScale, double zScale) { + if (cachedMesh == null) throw new IllegalArgumentException("cachedMesh cannot be null"); + validateHeightFieldRegion(heightField, startX, startZ, cellsX, cellsZ); + + directHeightFieldMesh = true; + directHeightField = heightField; + directStartX = startX; + directStartZ = startZ; + directCellsX = cellsX; + directCellsZ = cellsZ; + directVertsX = cellsX + 1; + directVertsZ = cellsZ + 1; + directXScale = xScale; + directYScale = yScale; + directZScale = zScale; + + listVertices.clear(); + listTextures.clear(); + listFaces.clear(); + smoothingGroups = null; + + setMesh(null); + mesh = cachedMesh; + setMesh(mesh); + } + + public boolean isDirectHeightFieldMesh() { + return directHeightFieldMesh; + } + + /** + * Restores spatial UV coordinates and applies an image directly to a primitive + * HeightField mesh. Faces are not rebuilt. + */ + public void setDirectTextureModeImage(Image image) { + if (!directHeightFieldMesh || mesh == null || image == null) return; + restoreDirectSpatialTexCoords(); + PhongMaterial material = directPhongMaterial(); + material.setDiffuseColor(Color.WHITE); + material.setDiffuseMap(image); + setMaterial(material); + } + + /** + * Colors a direct HeightField mesh by its orientation-neutral scaled source height + * using a compact rainbow palette. Only texture coordinates are updated; point and + * face buffers are retained. This keeps color semantics stable when the visual Y + * orientation is flipped. + */ + public void setDirectTextureModeByHeight(int colors, double min, double max) { + if (!directHeightFieldMesh || mesh == null) return; + if (colors < 2) throw new IllegalArgumentException("colors must be >= 2"); + if (!(max > min)) throw new IllegalArgumentException("max must be > min"); + + WritableImage palette = getOrCreateDirectPalette(colors); + ObservableFloatArray texCoords = mesh.getTexCoords(); + final float[] source = directHeightField.data(); + final int sourceWidth = directHeightField.width(); + final int sourceHeight = directHeightField.height(); + final float[] texRow = new float[directVertsX * 2]; + + for (int localZ = 0; localZ < directVertsZ; localZ++) { + int sampleZ = mapSourceRow(directStartZ + localZ, sourceHeight); + int sourceRow = sampleZ * sourceWidth; + int out = 0; + for (int localX = 0; localX < directVertsX; localX++) { + int sampleX = Math.min(directStartX + localX, sourceWidth - 1); + double y = source[sourceRow + sampleX] * directYScale; + double normalized = (y - min) / (max - min); + if (normalized < 0.0) normalized = 0.0; + else if (normalized > 1.0) normalized = 1.0; + int colorIndex = (int) Math.round(normalized * (colors - 1)); + texRow[out++] = (float) ((colorIndex + 0.5) / colors); + texRow[out++] = 0.5f; + } + texCoords.set(localZ * directVertsX * 2, + texRow, 0, texRow.length); + } + + PhongMaterial material = directPhongMaterial(); + material.setDiffuseColor(Color.WHITE); + material.setDiffuseMap(palette); + setMaterial(material); + } + protected final void updateMeshSmooth(int rangeX, int rangeY) { + clearDirectHeightFieldState(); setMesh(null); mesh = createSmoothMesh(getFunctionVert3D(), rangeX, rangeY, @@ -145,6 +371,7 @@ protected final void updateMeshSmooth(int rangeX, int rangeY) { @Override protected final void updateMesh() { + clearDirectHeightFieldState(); setMesh(null); mesh = createPlotMesh( getFunction2D(), @@ -330,6 +557,215 @@ public PolygonMesh getPolygonMesh() { return polygonMesh; } + private TriangleMesh createHeightFieldMesh(HeightField heightField, + int startX, int startZ, + int cellsX, int cellsZ, + double xScale, double yScale, double zScale) { + final int vertsX = cellsX + 1; + final int vertsZ = cellsZ + 1; + final long vertexCount = (long) vertsX * vertsZ; + final long pointFloatCount = vertexCount * 3L; + final long texFloatCount = vertexCount * 2L; + final long faceCount = (long) cellsX * cellsZ * 2L; + final long faceIntCount = faceCount * 6L; + + if (pointFloatCount > Integer.MAX_VALUE + || texFloatCount > Integer.MAX_VALUE + || faceCount > Integer.MAX_VALUE + || faceIntCount > Integer.MAX_VALUE) { + throw new IllegalArgumentException( + "HeightField region is too large for a JavaFX TriangleMesh: " + + cellsX + "x" + cellsZ); + } + + areaMesh.setWidth(cellsX); + areaMesh.setHeight(cellsZ); + rectMesh.setWidth(cellsX); + rectMesh.setHeight(cellsZ); + + TriangleMesh triangleMesh = new TriangleMesh(); + triangleMesh.getPoints().resize((int) pointFloatCount); + triangleMesh.getTexCoords().resize((int) texFloatCount); + + final float[] source = heightField.data(); + final int sourceWidth = heightField.width(); + final int sourceHeight = heightField.height(); + final float[] pointRow = new float[vertsX * 3]; + final float[] texRow = new float[vertsX * 2]; + + for (int localZ = 0; localZ < vertsZ; localZ++) { + final int globalVertexZ = startZ + localZ; + final int sampleZ = mapSourceRow(globalVertexZ, sourceHeight); + final int sourceRow = sampleZ * sourceWidth; + final float worldZ = (float) (localZ * zScale); + final float v = spatialV(globalVertexZ, sourceHeight); + + int p = 0; + int t = 0; + for (int localX = 0; localX < vertsX; localX++) { + final int globalVertexX = startX + localX; + final int sampleX = Math.min(globalVertexX, sourceWidth - 1); + final float worldX = (float) (localX * xScale); + final float worldY = (float) heightOrientation.toWorldY(source[sourceRow + sampleX], yScale); + final float u = (float) globalVertexX / (float) sourceWidth; + + pointRow[p++] = worldX; + pointRow[p++] = worldY; + pointRow[p++] = worldZ; + + texRow[t++] = u; + texRow[t++] = v; + } + + triangleMesh.getPoints().set(localZ * vertsX * 3, + pointRow, 0, pointRow.length); + triangleMesh.getTexCoords().set(localZ * vertsX * 2, + texRow, 0, texRow.length); + } + + // Connectivity depends only on local tile dimensions, not source position, + // height values, scale, or texture. Cache it once per cellsX/cellsZ pair and + // let JavaFX copy the immutable template into each TriangleMesh. + MeshTopology topology = heightFieldTopology(cellsX, cellsZ); + triangleMesh.getFaces().setAll(topology.faces()); + triangleMesh.getFaceSmoothingGroups().setAll(topology.smoothingGroups()); + + return triangleMesh; + } + + private static MeshTopology heightFieldTopology(int cellsX, int cellsZ) { + long key = (((long) cellsX) << 32) ^ (cellsZ & 0xffffffffL); + return HEIGHT_FIELD_TOPOLOGY_CACHE.computeIfAbsent(key, ignored -> buildHeightFieldTopology(cellsX, cellsZ)); + } + + private static MeshTopology buildHeightFieldTopology(int cellsX, int cellsZ) { + int vertsX = cellsX + 1; + int triangleCount = Math.multiplyExact(Math.multiplyExact(cellsX, cellsZ), 2); + int[] faces = new int[Math.multiplyExact(triangleCount, 6)]; + int[] groups = new int[triangleCount]; + Arrays.fill(groups, 1); + + int fi = 0; + for (int z = 0; z < cellsZ; z++) { + int rowStart = z * vertsX; + for (int x = 0; x < cellsX; x++) { + int p00 = rowStart + x; + int p01 = p00 + 1; + int p10 = p00 + vertsX; + int p11 = p10 + 1; + + // Front face points toward Y-. Point and texture indices match. + faces[fi++] = p00; faces[fi++] = p00; + faces[fi++] = p11; faces[fi++] = p11; + faces[fi++] = p10; faces[fi++] = p10; + + faces[fi++] = p11; faces[fi++] = p11; + faces[fi++] = p00; faces[fi++] = p00; + faces[fi++] = p01; faces[fi++] = p01; + } + } + return new MeshTopology(faces, groups); + } + + private void restoreDirectSpatialTexCoords() { + if (!directHeightFieldMesh || mesh == null || directHeightField == null) return; + final int sourceWidth = directHeightField.width(); + final int sourceHeight = directHeightField.height(); + final float[] texRow = new float[directVertsX * 2]; + + for (int localZ = 0; localZ < directVertsZ; localZ++) { + final int globalVertexZ = directStartZ + localZ; + final float v = spatialV(globalVertexZ, sourceHeight); + int t = 0; + for (int localX = 0; localX < directVertsX; localX++) { + final int globalVertexX = directStartX + localX; + texRow[t++] = (float) globalVertexX / (float) sourceWidth; + texRow[t++] = v; + } + mesh.getTexCoords().set(localZ * directVertsX * 2, + texRow, 0, texRow.length); + } + } + + private int mapSourceRow(int logicalRow, int sourceHeight) { + int clamped = Math.max(0, Math.min(logicalRow, sourceHeight - 1)); + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - 1 - clamped + : clamped; + } + + private float spatialV(int logicalVertexRow, int sourceHeight) { + float v = (float) logicalVertexRow / (float) sourceHeight; + if (v < 0.0f) v = 0.0f; + else if (v > 1.0f) v = 1.0f; + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? 1.0f - v + : v; + } + + private WritableImage getOrCreateDirectPalette(int colors) { + if (directPaletteImage != null && directPaletteColors == 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); + } + directPaletteImage = palette; + directPaletteColors = colors; + return palette; + } + + private PhongMaterial directPhongMaterial() { + if (getMaterial() instanceof PhongMaterial material) { + return material; + } + return new PhongMaterial(Color.WHITE); + } + + private void validateHeightFieldRegion(HeightField heightField, + int startX, int startZ, + int cellsX, int cellsZ) { + if (heightField == null) throw new IllegalArgumentException("heightField cannot be null"); + if (startX < 0 || startZ < 0) { + throw new IllegalArgumentException("startX/startZ must be >= 0"); + } + if (cellsX <= 0 || cellsZ <= 0) { + throw new IllegalArgumentException("cellsX/cellsZ must be > 0"); + } + if (startX >= heightField.width() || startZ >= heightField.height()) { + throw new IllegalArgumentException("region start is outside HeightField"); + } + if ((long) startX + cellsX > heightField.width() + || (long) startZ + cellsZ > heightField.height()) { + throw new IllegalArgumentException( + "region exceeds HeightField cell extent: start=" + startX + "," + startZ + + " cells=" + cellsX + "x" + cellsZ + + " field=" + heightField.width() + "x" + heightField.height()); + } + } + + private void clearDirectHeightFieldState() { + directHeightFieldMesh = false; + directHeightField = null; + directStartX = 0; + directStartZ = 0; + directCellsX = 0; + directCellsZ = 0; + directVertsX = 0; + directVertsZ = 0; + directXScale = Double.NaN; + directYScale = Double.NaN; + directZScale = Double.NaN; + } + private TriangleMesh createRawMesh(Function vertFunction, int rangeX, int rangeZ, double xScale, double yScale, double zScale) { listVertices.clear(); @@ -353,7 +789,7 @@ private TriangleMesh createRawMesh(Function vertFunction, dz = (float) (z * zScale); for (int x = 0; x <= rangeX; x++) { dx = (float) (x * xScale); - height = (float) yScale * vertFunction.apply(new Vert3D(dx, dz, x, z)).floatValue(); + height = (float) heightOrientation.toWorldY(vertFunction.apply(new Vert3D(dx, dz, x, z)).doubleValue(), yScale); functionIndex = (z * rangeX) + x; if (functionIndex < functionValues.size()) currentFValue = functionValues.get(functionIndex); @@ -364,10 +800,10 @@ private TriangleMesh createRawMesh(Function vertFunction, p01 = p00 + 1; p10 = p00 + numDivX; p11 = p10 + 1; - listTextures.add(new Face3(p00, p10, p11)); - listTextures.add(new Face3(p11, p01, p00)); - listFaces.add(new Face3(p00, p10, p11)); - listFaces.add(new Face3(p11, p01, p00)); + listTextures.add(new Face3(p00, p11, p10)); + listTextures.add(new Face3(p11, p00, p01)); + listFaces.add(new Face3(p00, p11, p10)); + listFaces.add(new Face3(p11, p00, p01)); } } @@ -418,6 +854,21 @@ public Float getMaxY() { return max; } + /** + * Returns the largest absolute world-space Y displacement in the mesh. Unlike + * getMaxY(), this remains meaningful when high values are rendered toward Y-. + */ + public Float getMaxAbsY() { + if (mesh == null || mesh.getPoints().size() == 0) return 0.0f; + float[] points = new float[mesh.getPoints().size()]; + mesh.getPoints().toArray(points); + float maxAbs = 0.0f; + for (int i = 1; i < points.length; i += 3) { + maxAbs = Math.max(maxAbs, Math.abs(points[i])); + } + return maxAbs; + } + private TriangleMesh createSmoothMesh(Function vertFunction, int rangeX, int rangeZ, int divisionsX, int divisionsZ, double yScale) { listVertices.clear(); listTextures.clear(); @@ -436,7 +887,7 @@ private TriangleMesh createSmoothMesh(Function vertFunction, int dz = ((float) z / (float) divisionsZ) * rangeZ; for (int x = 0; x <= divisionsX; x++) { dx = ((float) x / (float) divisionsX) * rangeX; - pointY = (float) yScale * vertFunction.apply(new Vert3D(dx, dz, x, z)).floatValue(); + pointY = (float) heightOrientation.toWorldY(vertFunction.apply(new Vert3D(dx, dz, x, z)).doubleValue(), yScale); listVertices.add(new Point3D(dx, pointY, dz)); } } @@ -447,10 +898,10 @@ private TriangleMesh createSmoothMesh(Function vertFunction, int p01 = p00 + 1; p10 = p00 + numDivX; p11 = p10 + 1; - listTextures.add(new Face3(p00, p10, p11)); - listTextures.add(new Face3(p11, p01, p00)); - listFaces.add(new Face3(p00, p10, p11)); - listFaces.add(new Face3(p11, p01, p00)); + listTextures.add(new Face3(p00, p11, p10)); + listTextures.add(new Face3(p11, p00, p01)); + listFaces.add(new Face3(p00, p11, p10)); + listFaces.add(new Face3(p11, p00, p01)); } } int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges @@ -475,7 +926,7 @@ private TriangleMesh createPlotMesh(Function function2D, double dy = (float) (((float) y / (float) divisionsY) * rangeY); for (int x = 0; x <= divisionsX; x++) { dx = (float) (((float) x / (float) divisionsX) * rangeX); - pointY = (float) scale * function2D.apply(new Point2D(dx, dy)).floatValue(); + pointY = (float) heightOrientation.toWorldY(function2D.apply(new Point2D(dx, dy)).doubleValue(), scale); listVertices.add(new Point3D(dx, pointY, dy)); } } @@ -491,10 +942,10 @@ private TriangleMesh createPlotMesh(Function function2D, double p01 = p00 + 1; p10 = p00 + numDivX; p11 = p10 + 1; - listTextures.add(new Face3(p00, p10, p11)); - listTextures.add(new Face3(p11, p01, p00)); - listFaces.add(new Face3(p00, p10, p11)); - listFaces.add(new Face3(p11, p01, p00)); + listTextures.add(new Face3(p00, p11, p10)); + listTextures.add(new Face3(p11, p00, p01)); + listFaces.add(new Face3(p00, p11, p10)); + listFaces.add(new Face3(p11, p00, p01)); } } int[] faceSmoothingGroups = new int[listFaces.size()]; // 0 == hard edges 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 2c53526c..3f63e5b9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -56,6 +56,8 @@ import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import javafx.embed.swing.SwingFXUtils; import javafx.event.ActionEvent; import javafx.event.Event; @@ -190,6 +192,11 @@ 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; boolean hoverInteractionsEnabled = false; boolean surfaceChartsEnabled = false; boolean crosshairsEnabled = false; @@ -207,6 +214,12 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} //allows 2D labels to track their 3D counterparts HashMap shape3DToLabel = new HashMap<>(); public List featureVectors = new ArrayList<>(); + + /** + * Authoritative boxed working/source grid retained for compatibility with existing + * analysis and ingestion paths. LOD selection must never replace this with a + * downsampled render grid; activeHeightField owns render-side data. + */ public List> dataGrid = new ArrayList<>(); private Random rando = new Random(); @@ -218,7 +231,8 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public float surfScale = DEFAULT_SURFSCALE; int TOTAL_COLORS = 1530; //colors used by map function - Function colorByHeight = p -> p.y; //Color mapping function + Function colorByHeight = + p -> getSurfaceHeightOrientation().toLogicalHeight(p.y); // orientation-neutral color mapping Function colorByShapley = p -> p.f; Function vert3DLookup = p -> vertToHeight(p); @@ -259,7 +273,73 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public PointLight pointLight; private List> originalGrid = new ArrayList<>(); - // Event-driven state fields for settings previously set via controls + + + // ============================================================ + // Primitive HeightField + Global LOD Cap (via LodManager) + // ============================================================ + + /** Max render dimension for L0 (aspect-preserving). Default safe limit: 2048. */ + private int maxRenderResolution = 2048; + /** Minimum LOD dimension for the legacy/global renderer. Default: 512. */ + private int minRenderResolution = 512; + + /** Tiled image rendering uses a fixed L0..L4 power-of-two pyramid. */ + private static final int TILED_LOD_LEVEL_COUNT = 5; + + /** Processing source HeightField. Image sources retain only capped raw L0; data grids retain full source resolution. */ + private HeightField processingSourceHeightField = null; + // Image-backed surfaces retain only the capped raw L0 HeightField. The original + // source dimensions/region are tracked separately so world extents and hover/source + // semantics remain tied to the input image rather than the render cap. + private int imageSourceStartX = 0; + private int imageSourceStartY = 0; + private int imageSourceWidth = 0; + private int imageSourceHeight = 0; + + /** Processed LOD pyramid (Option B: process L0 then downsample processed). */ + private List lodProcessedLevels = List.of(); + + /** Active LOD index into lodProcessedLevels. */ + private int activeLodIndex = -1; + + /** Active HeightField (alias of lodProcessedLevels.get(activeLodIndex)). */ + private HeightField activeHeightField = null; + + /** Constant world extents used to keep surface size stable across LOD changes. */ + private double baseWorldWidth = Double.NaN; + private double baseWorldDepth = Double.NaN; + + /** + * Image-backed surfaces use resolution-independent physical dimensions. The longer + * image axis occupies this many world units when surfScale == DEFAULT_SURFSCALE; + * the shorter axis is derived from the source aspect ratio. + */ + private boolean imageBackedSurface = false; + + /** + * True only while model state is being pushed into HypersurfaceControlsPane. + * GUI control listeners echo their changes back as HypersurfaceEvents, so those + * echoes must not be interpreted as user-requested geometry rebuilds. + */ + private boolean syncingGuiControls = false; + + private final double imageWorldExtentReferenceScale = Math.max(1.0e-6, DEFAULT_SURFSCALE); + private double nominalImageWorldExtent = DEFAULT_XWIDTH * (double) DEFAULT_SURFSCALE; + + /** Current mesh spacing in world units per cell for the active LOD. */ + private double currentLodSurfScaleX = Double.NaN; + private double currentLodSurfScaleZ = Double.NaN; + + /** Screen-space LOD scheduler/selector for the legacy/global renderer. */ + private LodManager lodManager = null; + + /** Tiled per-region renderer used for primitive image-backed heightfields. */ + private TiledSurfaceRenderer tiledSurfaceRenderer = null; + private boolean tiledHeightFieldRenderingEnabled = true; + private int tileCellsL0 = 256; + +// Event-driven state fields for settings previously set via controls private HeightMode heightMode = HeightMode.RAW; private boolean smoothingEnabled = false; private SurfaceUtils.Smoothing smoothingMethod = SurfaceUtils.Smoothing.GAUSSIAN; @@ -282,6 +362,18 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} public Hypersurface3DPane(Scene scene) { this.scene = scene; + surfaceRowOrientation.addListener((obs, oldValue, newValue) -> { + applySurfaceRowOrientationToRenderers(); + if (!suppressRowOrientationRefresh && surfPlot != null) { + updateTheMesh(); + } + }); + surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { + applySurfaceHeightOrientationToRenderers(); + if (surfPlot != null) { + updateTheMesh(); + } + }); shape3DToCalloutMap = new HashMap<>(); ambientLight = new AmbientLight(Color.WHITE); @@ -362,7 +454,8 @@ public Hypersurface3DPane(Scene scene) { double newZ = z + event.getZoomFactor() * modifierFactor * modifier; camera.setTranslateZ(newZ); updateLabels(); - }); + requestLodUpdate(LodManager.UpdateReason.SCROLL); +}); subScene.setOnKeyPressed(event -> { KeyCode keycode = event.getCode(); @@ -451,6 +544,7 @@ public Hypersurface3DPane(Scene scene) { updateLabels(); updateCalloutHeadPoints(subScene); + requestLodUpdate(LodManager.UpdateReason.OTHER); }); subScene.setOnMousePressed((MouseEvent me) -> { @@ -466,6 +560,7 @@ public Hypersurface3DPane(Scene scene) { else camera.setTranslateZ(camera.getTranslateZ() - 50.0); updateLabels(); updateCalloutHeadPoints(subScene); + requestLodUpdate(LodManager.UpdateReason.SCROLL); e.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { @@ -478,7 +573,8 @@ public Hypersurface3DPane(Scene scene) { camera.setTranslateZ(newZ); updateLabels(); updateCalloutHeadPoints(subScene); - }); + requestLodUpdate(LodManager.UpdateReason.SCROLL); +}); subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); Pane pathPane = App.getAppPathPaneStack(); @@ -494,7 +590,11 @@ public Hypersurface3DPane(Scene scene) { ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE)); }); - MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); + + subScene.setOnMouseReleased((MouseEvent me) -> { + forceLodUpdate(); + }); +MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); copyAsImageItem.setOnAction((ActionEvent e) -> { Clipboard clipboard = Clipboard.getSystemClipboard(); ClipboardContent content = new ClipboardContent(); @@ -568,6 +668,9 @@ public Hypersurface3DPane(Scene scene) { CheckMenuItem enableHoverItem = new CheckMenuItem("Hover Interactions"); enableHoverItem.setOnAction(e -> { hoverInteractionsEnabled = enableHoverItem.isSelected(); + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); + } }); CheckMenuItem surfaceChartsItem = new CheckMenuItem("Surface Charts"); @@ -597,9 +700,8 @@ public Hypersurface3DPane(Scene scene) { zWidth = DEFAULT_ZWIDTH; syncGuiControls(); generateRandos(xWidth, zWidth, yScale); - originalGrid = deepCopyGrid(dataGrid); - updateTheMesh(); - updateView(true); + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); }); CheckMenuItem showDataMarkersItem = new CheckMenuItem("Show Data Markers"); @@ -647,7 +749,7 @@ public Hypersurface3DPane(Scene scene) { applyGraphStyle(styleParams, /*rebuildIfNeeded*/ true); }); -// Reset style defaults + // Reset style defaults this.scene.addEventHandler(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, e -> { styleParams = new GraphStyleParams(); // back to defaults @@ -794,8 +896,9 @@ public void handle(long now) { long startTime; if (computeRandos) { generateRandos(xWidth, zWidth, yScale); - } - if (animated || isDirty) { + captureDataGridAsSource(); + rebuildProcessedGridAndRefresh(); + } else if (animated || isDirty) { startTime = System.nanoTime(); updateTheMesh(); LOG.info("updateTheMesh(): {}", Utils.totalTimeString(startTime)); @@ -895,7 +998,7 @@ private void applySurfaceGridToHypersurface(List> grid) { List> scaled = DataUtils.normalizeAndScale(grid, heightMode, userScale); dataGrid.clear(); dataGrid.addAll(scaled); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); syncGuiControls(); @@ -907,7 +1010,7 @@ public void setSurfaceFromDensity(GridDensityResult res, boolean useCDF, boolean if (flipY) Collections.reverse(grid); dataGrid.clear(); dataGrid.addAll(grid); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); syncGuiControls(); @@ -930,7 +1033,7 @@ public void computeSurfaceDifference(FeatureCollection collection) { } dataGrid.clear(); dataGrid.addAll(differencesGrid); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); syncGuiControls(); @@ -952,7 +1055,7 @@ public void computeVectorDistances() { }); dataGrid.clear(); dataGrid.addAll(distancesGrid); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); syncGuiControls(); @@ -1014,13 +1117,70 @@ public void addCallout(Callout callout, Shape3D shape3D) { } public void updateTheMesh() { - surfPlot.setVisible(surfaceRender); + if (surfPlot == null) return; + sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); + + final boolean useTiledRenderer = surfaceRender && shouldUseTiledHeightFieldRenderer(); + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setVisible(useTiledRenderer); + } + if (useTiledRenderer) { + surfPlot.setVisible(false); + if (paintMeshView != null) paintMeshView.setVisible(false); + configureTiledAppearance(); + tiledSurfaceRenderer.forceUpdate(); + return; + } + + // An image-backed surface with no active HeightField is between source + // replacement and LOD-pyramid activation (or is waiting for the global LOD + // selector). Never fall through to the legacy raw FXyz mesh builder here: + // xWidth/zWidth may still describe the full 4K/8K source and attempting a + // raw mesh at that resolution can exhaust the Java heap. + if (imageBackedSurface && activeHeightField == null) { + surfPlot.setVisible(false); + if (paintMeshView != null) paintMeshView.setVisible(false); + return; + } + + surfPlot.setVisible(surfaceRender); + if (paintMeshView != null) paintMeshView.setVisible(true); + + final int renderWidth = getRenderWidth(); + final int renderHeight = getRenderHeight(); + final double renderScaleX = getRenderScaleX(); + final double renderScaleZ = getRenderScaleZ(); + if (surfaceRender) { - surfPlot.updateMeshRaw(xWidth, zWidth, surfScale, yScale, surfScale); + surfPlot.setRowOrientation(getSurfaceRowOrientation()); + surfPlot.setHeightOrientation(getSurfaceHeightOrientation()); + // Primitive HeightField meshes bypass the FXyz Point3D/Face3 object graph. + // Shapley coloration temporarily retains the legacy path until its scalar + // field is made LOD-aware; image and height coloration use the fast path. + boolean useDirectHeightFieldMesh = activeHeightField != null + && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; + + long meshStart = System.nanoTime(); + if (useDirectHeightFieldMesh) { + surfPlot.updateMeshHeightField(activeHeightField, + renderScaleX, yScale, renderScaleZ); + System.out.println("Direct HeightField mesh built: " + + renderWidth + "x" + renderHeight + " in " + + Utils.totalTimeString(meshStart)); + } else { + surfPlot.updateMeshRaw(renderWidth, renderHeight, + renderScaleX, yScale, renderScaleZ); + } + applyCurrentColoration(); } else { - sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); - TessellationTube tube = new TessellationTube(dataGrid, Color.WHITE, yScale * 10, surfScale, yScale); + // Cylinder/tube mode still needs the legacy boxed representation, but it must + // use the bounded active render LOD rather than the full-resolution source. + List> renderGrid = activeHeightField != null + ? SurfaceUtils.toGrid(activeHeightField) + : dataGrid; + TessellationTube tube = new TessellationTube(renderGrid, Color.WHITE, + yScale * 10, renderScaleZ, yScale); tube.setMouseTransparent(true); if (null != lastImage) { tube.meshView.setDrawMode(DrawMode.FILL); @@ -1032,8 +1192,71 @@ public void updateTheMesh() { Platform.runLater(this::updatePaintMesh); } + /** + * Apply the currently selected surface coloration without rebuilding geometry. + * Image coloration uses the already-loaded JavaFX Image directly so it works for + * absolute file URLs as well as images that did not originate under imageryBasePath. + */ + private void applyCurrentColoration() { + if (surfPlot == null || colorationMethod == null) return; + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledAppearance(); + return; + } + + switch (colorationMethod) { + case COLOR_BY_IMAGE -> { + if (lastImage == null) return; + if (surfPlot.isDirectHeightFieldMesh()) { + surfPlot.setDirectTextureModeImage(lastImage); + } else { + PhongMaterial material; + if (surfPlot.getMaterial() instanceof PhongMaterial existing) { + material = existing; + } else { + material = new PhongMaterial(Color.WHITE); + } + material.setDiffuseColor(Color.WHITE); + material.setDiffuseMap(lastImage); + surfPlot.setMaterial(material); + } + } + case COLOR_BY_FEATURE -> { + if (surfPlot.isDirectHeightFieldMesh()) { + // Use one stable data range for the entire LOD pyramid so colors do + // not shift as the camera moves between L0/L1/L2. Image-backed + // HeightFields are typically around 0..1 before Y scaling, so the + // previous hard-coded 0..360 range collapsed almost all values into + // the red end of the palette. + HeightField colorRangeField = !lodProcessedLevels.isEmpty() + ? lodProcessedLevels.get(0) + : activeHeightField; + if (colorRangeField != null) { + float[] minMax = colorRangeField.minMax(); + double minColorValue = minMax[0] * yScale; + double maxColorValue = minMax[1] * yScale; + if (!(maxColorValue > minColorValue)) { + double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); + minColorValue -= pad; + maxColorValue += pad; + } + surfPlot.setDirectTextureModeByHeight( + TOTAL_COLORS, minColorValue, maxColorValue); + } + } else { + surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); + } + } + case COLOR_BY_SHAPLEY -> + surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); + } + } + public void updatePaintMesh() { - diffusePaintImage = new WritableImage(xWidth, zWidth); + 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); @@ -1109,8 +1332,8 @@ public void updatePaintMesh() { paintTriangleMesh.getFaces().setAll(faces); paintPhong.setDiffuseMap(diffusePaintImage); paintMeshView.setTranslateZ(-1); - paintMeshView.setTranslateX(-(xWidth * surfScale) / 2.0); - paintMeshView.setTranslateZ(-(zWidth * surfScale) / 2.0); + paintMeshView.setTranslateX(-getWorldWidth() / 2.0); + paintMeshView.setTranslateZ(-getWorldDepth() / 2.0); } public void paintSingleColor(Color color) { @@ -1123,8 +1346,10 @@ public void paintSingleColor(Color color) { public void illuminateCrosshair(Point3D center) { if (null == diffusePaintImage) return; - int x = (int) (center.getX() / surfScale); - int z = (int) (center.getZ() / surfScale); + 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); @@ -1197,6 +1422,7 @@ private void mouseDragCamera(MouseEvent me) { } updateLabels(); updateCalloutHeadPoints(subScene); + requestLodUpdate(LodManager.UpdateReason.DRAG); } private void updateLabels() { @@ -1208,6 +1434,61 @@ private void updateLabels() { }); } + private void handleSurfaceHover(Point3D surfacePoint) { + vertP3D = surfacePoint; + highlightedPoint.setTranslateX(vertP3D.x - getWorldWidth() / 2.0); + highlightedPoint.setTranslateY(vertP3D.y); + highlightedPoint.setTranslateZ(vertP3D.z - getWorldDepth() / 2.0); + updateCalloutHeadPoints(subScene); + updateLabels(); + + int row = Math.max(0, Math.min( + (int) Math.floor(vertP3D.getZ() / Math.max(1e-9, getRenderScaleZ())), + getRenderHeight() - 1)); + int column = Math.max(0, Math.min( + (int) Math.floor(vertP3D.getX() / Math.max(1e-9, getRenderScaleX())), + getRenderWidth() - 1)); + int orientedRenderRow = orientRowIndex(row, getRenderHeight()); + int sourceRow = mapRenderIndexToSource( + orientedRenderRow, getRenderHeight(), getSourceHeight()); + + if (anchorCallout != null) { + if (sourceRow < featureVectors.size()) { + updateCalloutByFeatureVector(anchorCallout, featureVectors.get(sourceRow)); + } + setSpheroidAnchor(false, sourceRow); + } + if (crosshairsEnabled && diffusePaintImage != null) { + paintSingleColor(Color.TRANSPARENT); + illuminateCrosshair(vertP3D); + } + if (surfaceChartsEnabled && activeHeightField != null) { + List xlist = getActiveRenderRow(row); + Double[] xRay = xlist.toArray(Double[]::new); + Double[] zRay = getActiveRenderColumn(column); + String text = "Coordinates: " + column + ", " + row + System.lineSeparator(); + text = text.concat("Value: ") + .concat(String.valueOf(getActiveRenderValue(row, column))) + .concat(System.lineSeparator()); + double maxX = xlist.stream().max(Double::compare).orElse(0.0); + text = text.concat("Max X: ").concat(String.valueOf(maxX)).concat(System.lineSeparator()); + double minX = xlist.stream().min(Double::compare).orElse(0.0); + text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); + double maxZ = Arrays.stream(zRay).max(Double::compare).orElse(0.0); + text = text.concat("Max Z: ").concat(String.valueOf(maxZ)).concat(System.lineSeparator()); + double minZ = Arrays.stream(zRay).min(Double::compare).orElse(0.0); + text = text.concat("Min Z: ").concat(String.valueOf(minZ)).concat(System.lineSeparator()); + hoverText.setText(text); + hoverText.setStrokeWidth(1); + hoverText.setLayoutX(50); + hoverText.setLayoutY(50); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.SURFACE_XFACTOR_VECTOR, xRay)); + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.SURFACE_ZFACTOR_VECTOR, zRay)); + } + } + private ImageView loadImageView(FeatureVector featureVector, boolean bboxOnly) { ImageView iv = null; try { @@ -1255,21 +1536,38 @@ private static double frac(double v) { } private Number vertToHeight(Vert3D p) { - if (dataGrid == null) return 0.0; - if (!surfaceRender) { - // cylinder path unchanged + // cylinder path unchanged (uses dataGrid) + if (dataGrid == null) return 0.0; return findBlerpHeight(p); } + + // Prefer primitive active LOD field + if (activeHeightField == null) { + if (dataGrid == null) return 0.0; + switch (interpMode) { + case BILINEAR: + case BICUBIC: { + double gx = p.xIndex + frac(p.getX() / Math.max(1.0, surfScale)); + double gy = p.yIndex + frac(p.getY() / Math.max(1.0, surfScale)); + gy = orientRowCoordinate(gy, dataGrid.size()); + return SurfaceUtils.sample(dataGrid, gx, gy, interpMode); + } + case NEAREST: + default: + return lookupPoint(p); + } + } + switch (interpMode) { case BILINEAR: case BICUBIC: { - // Convert to grid space: index + in-cell fraction. - // If p.getX()/getY() are already grid-space, this still works. - // If they are world-space, the /surfScale fixes it. - double gx = p.xIndex + frac(p.getX() / Math.max(1.0, surfScale)); - double gy = p.yIndex + frac(p.getY() / Math.max(1.0, surfScale)); - return SurfaceUtils.sample(dataGrid, gx, gy, interpMode); + double sx = Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; + double sz = Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; + double gx = p.xIndex + frac(p.getX() / Math.max(1.0, sx)); + double gy = p.yIndex + frac(p.getY() / Math.max(1.0, sz)); + gy = orientRowCoordinate(gy, activeHeightField.height()); + return SurfaceUtils.sample(activeHeightField, gx, gy, interpMode); } case NEAREST: default: @@ -1278,11 +1576,45 @@ private Number vertToHeight(Vert3D p) { } private Number lookupPoint(Vert3D p) { - if (p.yIndex >= dataGrid.size() || p.xIndex >= dataGrid.get(0).size()) return 0.0; - return dataGrid.get(p.yIndex).get(p.xIndex); + if (activeHeightField != null) { + int w = activeHeightField.width(); + int h = activeHeightField.height(); + if (p.yIndex < 0 || p.yIndex >= h || p.xIndex < 0 || p.xIndex >= w) return 0.0; + int dataRow = orientRowIndex(p.yIndex, h); + return (double) activeHeightField.data()[dataRow * w + p.xIndex]; + } + if (dataGrid == null) return 0.0; + if (p.yIndex < 0 || p.yIndex >= dataGrid.size() + || p.xIndex < 0 || p.xIndex >= dataGrid.get(0).size()) return 0.0; + int dataRow = orientRowIndex(p.yIndex, dataGrid.size()); + return dataGrid.get(dataRow).get(p.xIndex); } private Number findBlerpHeight(Vert3D p) { + // Used by cylinder mode (and legacy paths). Works on activeHeightField if present. + if (activeHeightField != null) { + int w = activeHeightField.width(); + int h = activeHeightField.height(); + if (w <= 0 || h <= 0) return 0.0; + + int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; + if (x1Index >= w - 1) x1Index = w - 1; + int x2Index = p.xIndex >= w - 1 ? w - 1 : p.xIndex + 1; + + int y1Index = p.yIndex <= 0 ? 0 : p.yIndex - 1; + if (y1Index >= h - 1) y1Index = h - 1; + int y2Index = p.yIndex >= h - 1 ? h - 1 : p.yIndex + 1; + + float[] d = activeHeightField.data(); + double c11 = d[y1Index * w + x1Index] * yScale; + double c21 = d[y1Index * w + x2Index] * yScale; + double c12 = d[y2Index * w + x1Index] * yScale; + double c22 = d[y2Index * w + x2Index] * yScale; + return quickBlerp(c11, c21, c12, c22, p.getX(), p.getY()); + } + + if (dataGrid == null) return 0.0; + int x1Index = p.xIndex <= 0 ? 0 : p.xIndex - 1; if (x1Index >= dataGrid.get(0).size() - 1) x1Index = dataGrid.get(0).size() - 1; int x2Index = p.xIndex >= dataGrid.get(0).size() - 1 ? dataGrid.get(0).size() - 1 : p.xIndex + 1; @@ -1304,73 +1636,78 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, return f12 + (f34 - f12) * yratio; } - int vert; Point3D vertP3D; private void loadSurf3D() { LOG.info("Rendering Hypersurface Mesh..."); generateRandos(xWidth, zWidth, yScale); - originalGrid = deepCopyGrid(dataGrid); // NEW + 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.LINE); + surfPlot.setDrawMode(DrawMode.FILL); sceneRoot.getChildren().add(surfPlot); - surfPlot.setCullFace(CullFace.NONE); + surfPlot.setCullFace(CullFace.BACK); surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { - if (hoverInteractionsEnabled) { - javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); - vertP3D = Point3D.convertFromJavaFXPoint3D(p3D); - highlightedPoint.setTranslateX(vertP3D.x - (xWidth * surfScale) / 2.0); - highlightedPoint.setTranslateY(vertP3D.y); - highlightedPoint.setTranslateZ(vertP3D.z - (zWidth * surfScale) / 2.0); - updateCalloutHeadPoints(subScene); - updateLabels(); - int row = Float.valueOf(vertP3D.getZ() / surfScale).intValue(); - int column = Float.valueOf(vertP3D.getX() / surfScale).intValue(); - if (null != anchorCallout) { - if (row < featureVectors.size()) updateCalloutByFeatureVector(anchorCallout, featureVectors.get(row)); - setSpheroidAnchor(false, row); - } - if (crosshairsEnabled) { - paintSingleColor(Color.TRANSPARENT); - illuminateCrosshair(vertP3D); - } - if (surfaceChartsEnabled) { - List xlist = dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))); - Double[] xRay = xlist.toArray(Double[]::new); - Double[] zRay = new Double[dataGrid.size()]; - for (int i = 0; i < dataGrid.size(); i++) zRay[i] = dataGrid.get(i).get(Math.max(0, Math.min(column, dataGrid.get(0).size() - 1))); - String text = "Coordinates: " + column + ", " + row + System.lineSeparator(); - text = text.concat("Value: ").concat(String.valueOf(dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))).get(Math.max(0, Math.min(column, dataGrid.get(0).size() - 1))))).concat(System.lineSeparator()); - double maxX = xlist.stream().max(Double::compare).get(); - text = text.concat("Max X: ").concat(String.valueOf(maxX)).concat(System.lineSeparator()); - double minX = xlist.stream().min(Double::compare).get(); - text = text.concat("Min X: ").concat(String.valueOf(minX)).concat(System.lineSeparator()); - double maxZ = Arrays.stream(zRay).max(Double::compare).get(); - text = text.concat("Max Z: ").concat(String.valueOf(maxZ)).concat(System.lineSeparator()); - double minZ = Arrays.stream(zRay).min(Double::compare).get(); - 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)); - } + + + // Initialize LOD manager (screen-space selection + throttle/debounce scheduling) + if (lodManager == null) { + lodManager = new LodManager(camera, subScene, surfPlot); + LodManager.LodConfig cfg = new LodManager.LodConfig(); + cfg.targetPixelsPerCell = 1.5; + cfg.lowThreshold = 1.0; + cfg.highThreshold = 2.0; + cfg.throttleMs = 75; + cfg.debounceMs = 75; + lodManager.setConfig(cfg); + lodManager.setOnLodSelected(idx -> { + if (!shouldUseTiledHeightFieldRenderer()) applyActiveLodIndex(idx); + }); + } + + if (tiledSurfaceRenderer == null) { + tiledSurfaceRenderer = new TiledSurfaceRenderer(camera, subScene); + tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); + tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); + tiledSurfaceRenderer.setVisible(false); + tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); + sceneRoot.getChildren().add(tiledSurfaceRenderer); + tiledSurfaceRenderer.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { + if (!hoverInteractionsEnabled) return; + Node picked = e.getPickResult().getIntersectedNode(); + javafx.geometry.Point3D pickedPoint = e.getPickResult().getIntersectedPoint(); + if (picked == null || pickedPoint == null) return; + javafx.geometry.Point3D scenePoint = picked.localToScene(pickedPoint); + javafx.geometry.Point3D surfacePoint = tiledSurfaceRenderer.sceneToLocal(scenePoint); + handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(surfacePoint)); e.consume(); - } + }); + } + + // Build processed pyramid and apply initial LOD. The primitive source cache is + // created by rebuildProcessedGridAndRefresh() from the current source snapshot. + rebuildProcessedGridAndRefresh(); + + surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { + if (!hoverInteractionsEnabled) return; + javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); + if (p3D == null) return; + handleSurfaceHover(Point3D.convertFromJavaFXPoint3D(p3D)); + e.consume(); }); Glow glow = new Glow(0.8); double poleHeight = 60; double radius = 3; - glowLineBox = new Box(xWidth * surfScale, poleHeight, radius); + 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(-(zWidth * surfScale) / 2.0); + 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); @@ -1382,10 +1719,10 @@ private void loadSurf3D() { westPole.setMaterial(westPoleMaterial); eastKnob.setMaterial(knobMaterial); westKnob.setMaterial(knobMaterial); - eastPole.setTranslateX((xWidth * surfScale) / 2.0); - westPole.setTranslateX(-(xWidth * surfScale) / 2.0); - eastKnob.setTranslateX((xWidth * surfScale) / 2.0); - westKnob.setTranslateX(-(xWidth * surfScale) / 2.0); + 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()); @@ -1407,9 +1744,9 @@ private void loadSurf3D() { anchorIndex = (int) e.object; if (anchorIndex < 0) anchorIndex = 0; else if (anchorIndex > dataGrid.size()) anchorIndex = dataGrid.size(); - glowLineBox.setTranslateZ((anchorIndex * surfScale) - ((zWidth * surfScale) / 2.0)); + glowLineBox.setTranslateZ((anchorIndex * surfScale) - (getWorldDepth() / 2.0)); setSpheroidAnchor(true, anchorIndex); - eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + xWidth); + eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + getSourceWidth()); westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); updateLabels(); updateCalloutHeadPoints(subScene); @@ -1424,12 +1761,12 @@ private void loadSurf3D() { extrasGroup.getChildren().addAll(eastPole, eastKnob, westPole, westKnob, glowLineBox); wireEventHandlers(); - pointLight.getScope().addAll(surfPlot, graphLayer); + 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, graphLayer); + ambientLight.getScope().addAll(surfPlot, tiledSurfaceRenderer, graphLayer); sceneRoot.getChildren().add(ambientLight); updateLabels(); @@ -1447,12 +1784,19 @@ private void loadSurf3D() { * (xWidth, zWidth, yScale, surfScale) to synchronize GUI controls with model state. */ public void syncGuiControls() { - // Fire events to update GUI controls in the controls pane - // These will be handled by HypersurfaceControlsPane to update Spinner values. - fireOnRoot(HypersurfaceEvent.setXWidthGUI(xWidth)); - fireOnRoot(HypersurfaceEvent.setZWidthGUI(zWidth)); - fireOnRoot(HypersurfaceEvent.setYScaleGUI(yScale)); - fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); + // SET_*_GUI events update Spinner values synchronously. Those Spinner value + // listeners echo XWIDTH_CHANGED/ZWIDTH_CHANGED/etc. back through the Scene. + // Mark the entire model -> GUI synchronization window so those echoes do not + // trigger geometry rebuilds. + syncingGuiControls = true; + try { + fireOnRoot(HypersurfaceEvent.setXWidthGUI(xWidth)); + fireOnRoot(HypersurfaceEvent.setZWidthGUI(zWidth)); + fireOnRoot(HypersurfaceEvent.setYScaleGUI(yScale)); + fireOnRoot(HypersurfaceEvent.setSurfScaleGUI(surfScale)); + } finally { + syncingGuiControls = false; + } } /** @@ -1474,8 +1818,11 @@ private void wireEventHandlers() { if (scene == null) return; // Geometry / scale scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; this.xWidth = (int) e.object; - if (surfPlot != null) { + // When an LOD pyramid is active, its HeightField dimensions own the render + // mesh. Do not let GUI synchronization overwrite the constant-world transform. + if (surfPlot != null && activeHeightField == null) { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } @@ -1483,8 +1830,9 @@ private void wireEventHandlers() { }); scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; this.zWidth = (int) e.object; - if (surfPlot != null) { + if (surfPlot != null && activeHeightField == null) { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); } @@ -1492,19 +1840,16 @@ 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); updateTheMesh(); }); scene.addEventHandler(HypersurfaceEvent.SURF_SCALE_CHANGED, e -> { + if (syncingGuiControls) return; this.surfScale = ((Double) e.object).floatValue(); - if (surfPlot != null) { - surfPlot.setRangeX(xWidth * surfScale); - surfPlot.setRangeY(zWidth * surfScale); - surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); - surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); - } + refreshWorldExtentsAndLodMetadata(); updateTheMesh(); }); @@ -1514,14 +1859,43 @@ private void wireEventHandlers() { updateTheMesh(); }); scene.addEventHandler(HypersurfaceEvent.DRAW_MODE_CHANGED, e -> { - if (surfPlot != null) surfPlot.setDrawMode((DrawMode) e.object); + DrawMode drawMode = (DrawMode) e.object; + if (surfPlot != null) surfPlot.setDrawMode(drawMode); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setDrawMode(drawMode); }); scene.addEventHandler(HypersurfaceEvent.CULL_FACE_CHANGED, e -> { - if (surfPlot != null) surfPlot.setCullFace((CullFace) e.object); + CullFace cullFace = (CullFace) e.object; + if (surfPlot != null) surfPlot.setCullFace(cullFace); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setCullFace(cullFace); }); scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { - this.colorationMethod = (Hypersurface3DPane.COLORATION) e.object; - updateTheMesh(); + COLORATION previous = this.colorationMethod; + COLORATION next = (Hypersurface3DPane.COLORATION) e.object; + this.colorationMethod = next; + + // Shapley currently uses the legacy mesh metadata path. Crossing into or + // out of that mode therefore requires a geometry rebuild. Image <-> feature + // coloration only changes UV/material state on the direct mesh. + boolean shapleyPathChanged = (previous == COLORATION.COLOR_BY_SHAPLEY) + != (next == COLORATION.COLOR_BY_SHAPLEY); + if (shapleyPathChanged) { + if (next == COLORATION.COLOR_BY_SHAPLEY + && lodManager != null + && lodProcessedLevels != null + && !lodProcessedLevels.isEmpty()) { + activeLodIndex = -1; + activeHeightField = null; + lodManager.setPyramid( + LodManager.fromHeightFields(lodProcessedLevels), + baseWorldWidth, baseWorldDepth, -1); + lodManager.forceUpdate(); + } else if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + } + updateTheMesh(); + } else { + applyCurrentColoration(); + } }); // Processing pipeline @@ -1573,12 +1947,19 @@ private void wireEventHandlers() { // (Optional: enable/disable pointLight as desired) }); scene.addEventHandler(HypersurfaceEvent.SPECULAR_COLOR_CHANGED, e -> { + Color color = (Color) e.object; if (surfPlot != null && surfPlot.getMaterial() instanceof PhongMaterial mat) - mat.setSpecularColor((Color) e.object); + mat.setSpecularColor(color); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setSpecularColor(color); }); // UX toggles - scene.addEventHandler(HypersurfaceEvent.HOVER_ENABLE_CHANGED, e -> hoverInteractionsEnabled = (boolean) e.object); + scene.addEventHandler(HypersurfaceEvent.HOVER_ENABLE_CHANGED, e -> { + hoverInteractionsEnabled = (boolean) e.object; + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); + } + }); scene.addEventHandler(HypersurfaceEvent.SURFACE_CHARTS_ENABLE_CHANGED, e -> surfaceChartsEnabled = (boolean) e.object); scene.addEventHandler(HypersurfaceEvent.DATA_MARKERS_ENABLE_CHANGED, e -> extrasGroup.setVisible((boolean) e.object)); scene.addEventHandler(HypersurfaceEvent.CROSSHAIRS_ENABLE_CHANGED, e -> crosshairsEnabled = (boolean) e.object); @@ -1700,6 +2081,7 @@ public void clearAll() { dataGrid.clear(); featureVectors.clear(); originalGrid.clear(); + resetLodDataState(); } public void showAll() { @@ -1808,24 +2190,24 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection zWidth = neuralData.size(); xWidth = neuralData.get(0).size() / 2; syncGuiControls(); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); rebuildProcessedGridAndRefresh(); // NEW - xSphere.setTranslateX((xWidth * surfScale) / 2.0); - zSphere.setTranslateZ((zWidth * surfScale) / 2.0); + xSphere.setTranslateX(getWorldWidth() / 2.0); + zSphere.setTranslateZ(getWorldDepth() / 2.0); - double poleHeight = surfPlot.getMaxY() * 2; - glowLineBox.setWidth(xWidth * surfScale); + double poleHeight = surfPlot.getMaxAbsY() * 2; + glowLineBox.setWidth(getWorldWidth()); glowLineBox.setHeight(poleHeight); eastPole.setHeight(poleHeight * 1.2); westPole.setHeight(poleHeight * 1.2); - eastPole.setTranslateX(-(xWidth * surfScale) / 2.0); - westPole.setTranslateX((xWidth * surfScale) / 2.0); - eastKnob.setTranslateX((xWidth * surfScale) / 2.0); - westKnob.setTranslateX(-(xWidth * surfScale) / 2.0); + 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: " + xWidth); + eastLabel.setText("Sample: " + anchorIndex + ", Neural Feature: " + getSourceWidth()); westLabel.setText("Sample: " + anchorIndex + ", Neural Feature: 0"); updateLabels(); } @@ -1863,7 +2245,7 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl zWidth = dataGrid.size(); xWidth = dataGrid.get(0).size(); syncGuiControls(); - originalGrid = deepCopyGrid(dataGrid); + captureDataGridAsSource(); rebuildProcessedGridAndRefresh(); getScene().getRoot().fireEvent(new CommandTerminalEvent("Hypersurface updated. ", new Font("Consolas", 20), Color.GREEN)); featureVectors = featureCollection.getFeatures(); @@ -1873,7 +2255,7 @@ public void addFeatureCollection(FeatureCollection featureCollection, boolean cl public void addFeatureVector(FeatureVector featureVector) { featureVectors.add(featureVector); dataGrid.add(featureVector.getData()); - originalGrid = deepCopyGrid(dataGrid); + captureDataGridAsSource(); rebuildProcessedGridAndRefresh(); } @@ -1887,6 +2269,7 @@ public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); originalGrid.clear(); + resetLodDataState(); } @Override @@ -1930,47 +2313,138 @@ 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) { - lastImage = image; + final int imageWidth = (int) image.getWidth(); + final int imageHeight = (int) image.getHeight(); + final int startX = Math.max(0, Math.min(x1, imageWidth)); + final int startY = Math.max(0, Math.min(y1, imageHeight)); + final int endX = Math.max(startX, Math.min(x2, imageWidth)); + final int endY = Math.max(startY, Math.min(y2, imageHeight)); + final int sourceWidth = endX - startX; + final int sourceHeight = endY - startY; + + if (sourceWidth <= 0 || sourceHeight <= 0) { + LOG.warn("Ignoring empty image tessellation region: ({}, {}) to ({}, {}) for {}x{} image", + x1, y1, x2, y2, imageWidth, imageHeight); + return; + } + long startTime = System.nanoTime(); - LOG.info("Mapping Image Raster to Feature Vector... "); - int rows = (int) image.getHeight(); - int columns = (int) image.getWidth(); - PixelReader pr = image.getPixelReader(); - Color color = null; - int rgb, r, g, b = 0; - double dataValue = 0; - if (null == dataGrid) dataGrid = new ArrayList<>(rows); - else dataGrid.clear(); - featureVectors.clear(); - for (int rowIndex = y1; rowIndex < y2; rowIndex++) { - List currentDataRow = new ArrayList<>(); - for (int colIndex = x1; colIndex < x2; colIndex++) { - color = pr.getColor(colIndex, rowIndex); - rgb = (pr.getArgb(colIndex, rowIndex)); - FeatureVector fv = FeatureVector.EMPTY_FEATURE_VECTOR(color.toString(), 3); - fv.getData().set(0, (double) colIndex / columns); - fv.getData().set(1, (double) rowIndex / rows); - r = (rgb >> 16) & 0xFF; - g = (rgb >> 8) & 0xFF; - b = rgb & 0xFF; - dataValue = (((r + g + b) / 3.0) / 255.0); - fv.getData().set(2, dataValue); - featureVectors.add(fv); - currentDataRow.add(dataValue); - } - dataGrid.add(currentDataRow); + System.out.println("Mapping Image Raster directly to capped primitive L0: " + + sourceWidth + "x" + sourceHeight); + + PixelReader pixelReader = image.getPixelReader(); + if (pixelReader == null) { + LOG.warn("Unable to tessellate image because PixelReader is unavailable."); + return; } + + // Validate the incoming image first, then release every large render-side object + // derived from the previous image before allocating the new full-resolution + // primitive height array. This minimizes the transient heap peak when replacing + // 4K/8K sources. The method parameter retains the new Image while lastImage and + // the tiled renderer are allowed to release the old one. + releaseImageBackedStateForReplacement(); + lastImage = image; + + // Retain source-region metadata, but do not allocate a full-resolution + // HeightField for large images. Build the capped raw L0 directly from PixelReader. + imageSourceStartX = startX; + imageSourceStartY = startY; + imageSourceWidth = sourceWidth; + imageSourceHeight = sourceHeight; + processingSourceHeightField = buildImageRawL0(pixelReader, + startX, startY, sourceWidth, sourceHeight, maxRenderResolution); + + // Image-backed hypersurfaces use the capped primitive L0 as the authoritative + // processing source. Do not manufacture one FeatureVector or boxed Double per pixel. + featureVectors.clear(); + if (dataGrid == null) dataGrid = new ArrayList<>(); + else dataGrid.clear(); + if (originalGrid == null) originalGrid = new ArrayList<>(); + else originalGrid.clear(); + imageBackedSurface = true; + + // A new primitive source invalidates only state derived from the previous source. + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + baseWorldWidth = Double.NaN; + baseWorldDepth = Double.NaN; + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_FAR); + Utils.printTotalTime(startTime); - LOG.info("Injecting Mesh into Hypersurface... "); + System.out.println("Injecting primitive image HeightField into Hypersurface..."); startTime = System.nanoTime(); - zWidth = rows; - xWidth = columns; + + xWidth = sourceWidth; + zWidth = sourceHeight; syncGuiControls(); - originalGrid = deepCopyGrid(dataGrid); rebuildProcessedGridAndRefresh(); - xSphere.setTranslateX((xWidth * surfScale) / 2.0); - zSphere.setTranslateZ((zWidth * surfScale) / 2.0); + xSphere.setTranslateX(getWorldWidth() / 2.0); + zSphere.setTranslateZ(getWorldDepth() / 2.0); Utils.printTotalTime(startTime); } @@ -1993,11 +2467,11 @@ public void addShapleyCollection(ShapleyCollection shapleyCollection) { surfPlot.functionValues.add(shapleyVectors.get(i).getData().get(0) * yScale); } Utils.printTotalTime(startTime); - if (null == colorationMethod) surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); - switch (colorationMethod) { - case COLOR_BY_IMAGE -> surfPlot.setTextureModeImage(imageryBasePath + lastImageSource); - case COLOR_BY_FEATURE -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); - default -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); + if (colorationMethod == COLORATION.COLOR_BY_SHAPLEY) { + // Rebuild through the legacy metadata path so p.f remains available. + updateTheMesh(); + } else { + applyCurrentColoration(); } } } catch (IOException ex) { @@ -2022,22 +2496,604 @@ private static List> deepCopyGrid(List> src) { return out; } + /** + * Releases render-side state owned by the previous image before a replacement + * image allocates its capped primitive L0 HeightField. Keeping this separate + * from resetLodDataState() preserves the incoming source type/orientation workflow + * while aggressively dropping references to large cached TriangleMeshes, LOD + * HeightFields, and the previous JavaFX Image. + */ + private void releaseImageBackedStateForReplacement() { + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.clearSurface(); + tiledSurfaceRenderer.setVisible(false); + } + if (surfPlot != null) { + surfPlot.setVisible(false); + } + if (paintMeshView != null) { + paintMeshView.setVisible(false); + } + + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + baseWorldWidth = Double.NaN; + baseWorldDepth = Double.NaN; + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + + // Drop the pane's reference to the previous image. The incoming Image remains + // strongly referenced by tessellateImage() and is assigned after this release. + lastImage = null; + } + + /** + * Snapshot the current boxed working grid as the authoritative source for the + * render pipeline. Any primitive source cache and render pyramid derived from the + * previous source become invalid immediately. + */ + private void captureDataGridAsSource() { + // Boxed grids represent data/FeatureCollection-backed surfaces, whose historical + // world-size behavior remains sourceDimension * surfScale. + boolean wasImageBacked = imageBackedSurface; + imageBackedSurface = false; + if (wasImageBacked) { + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); + } + if (dataGrid == null || dataGrid.isEmpty()) { + originalGrid = new ArrayList<>(); + resetLodDataState(); + return; + } + originalGrid = deepCopyGrid(dataGrid); + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; + } + + private void resetLodDataState() { + imageBackedSurface = false; + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + baseWorldWidth = Double.NaN; + baseWorldDepth = Double.NaN; + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.clearSurface(); + tiledSurfaceRenderer.setVisible(false); + } + } + + private int getSourceWidth() { + if (imageBackedSurface && imageSourceWidth > 0) return imageSourceWidth; + if (processingSourceHeightField != null) return processingSourceHeightField.width(); + if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.get(0).size(); + if (dataGrid != null && !dataGrid.isEmpty()) return dataGrid.get(0).size(); + return Math.max(1, xWidth); + } + + private int getSourceHeight() { + if (imageBackedSurface && imageSourceHeight > 0) return imageSourceHeight; + if (processingSourceHeightField != null) return processingSourceHeightField.height(); + if (originalGrid != null && !originalGrid.isEmpty()) return originalGrid.size(); + if (dataGrid != null && !dataGrid.isEmpty()) return dataGrid.size(); + return Math.max(1, zWidth); + } + + private int getRenderWidth() { + return activeHeightField != null ? activeHeightField.width() : Math.max(1, xWidth); + } + + private int getRenderHeight() { + return activeHeightField != null ? activeHeightField.height() : Math.max(1, zWidth); + } + + private double getRenderScaleX() { + return Double.isFinite(currentLodSurfScaleX) ? currentLodSurfScaleX : surfScale; + } + + private double getRenderScaleZ() { + return Double.isFinite(currentLodSurfScaleZ) ? currentLodSurfScaleZ : surfScale; + } + + private double getWorldWidth() { + if (Double.isFinite(baseWorldWidth)) return baseWorldWidth; + int sourceWidth = getSourceWidth(); + int sourceHeight = getSourceHeight(); + return calculateWorldWidth(sourceWidth, sourceHeight); + } + + private double getWorldDepth() { + if (Double.isFinite(baseWorldDepth)) return baseWorldDepth; + int sourceWidth = getSourceWidth(); + int sourceHeight = getSourceHeight(); + return calculateWorldDepth(sourceWidth, sourceHeight); + } + + private double imageWorldLongAxisExtent() { + return nominalImageWorldExtent * (surfScale / imageWorldExtentReferenceScale); + } + + private double calculateWorldWidth(int sourceWidth, int sourceHeight) { + if (!imageBackedSurface) return sourceWidth * surfScale; + if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; + double longAxis = imageWorldLongAxisExtent(); + if (sourceWidth >= sourceHeight) return longAxis; + return longAxis * sourceWidth / (double) sourceHeight; + } + + private double calculateWorldDepth(int sourceWidth, int sourceHeight) { + if (!imageBackedSurface) return sourceHeight * surfScale; + if (sourceWidth <= 0 || sourceHeight <= 0) return nominalImageWorldExtent; + double longAxis = imageWorldLongAxisExtent(); + if (sourceHeight >= sourceWidth) return longAxis; + return longAxis * sourceHeight / (double) sourceWidth; + } + + private void recomputeBaseWorldExtents() { + int sourceWidth = getSourceWidth(); + int sourceHeight = getSourceHeight(); + baseWorldWidth = calculateWorldWidth(sourceWidth, sourceHeight); + baseWorldDepth = calculateWorldDepth(sourceWidth, sourceHeight); + } + + private static int mapRenderIndexToSource(int renderIndex, int renderSize, int sourceSize) { + if (sourceSize <= 1 || renderSize <= 1) return 0; + double sourceCoordinate = ((renderIndex + 0.5) * sourceSize / (double) renderSize) - 0.5; + return Math.max(0, Math.min((int) Math.round(sourceCoordinate), sourceSize - 1)); + } + + private double getActiveRenderValue(int row, int column) { + if (activeHeightField == null) return 0.0; + int r = orientRowIndex(row, activeHeightField.height()); + int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); + return activeHeightField.get(c, r); + } + + private List getActiveRenderRow(int row) { + if (activeHeightField == null) return List.of(); + int r = orientRowIndex(row, activeHeightField.height()); + int w = activeHeightField.width(); + float[] values = activeHeightField.data(); + List out = new ArrayList<>(w); + int offset = r * w; + for (int x = 0; x < w; x++) out.add((double) values[offset + x]); + return out; + } + + private Double[] getActiveRenderColumn(int column) { + if (activeHeightField == null) return new Double[0]; + int c = Math.max(0, Math.min(column, activeHeightField.width() - 1)); + int w = activeHeightField.width(); + int h = activeHeightField.height(); + float[] values = activeHeightField.data(); + Double[] out = new Double[h]; + for (int visualRow = 0; visualRow < h; visualRow++) { + int sourceRow = orientRowIndex(visualRow, h); + out[visualRow] = (double) values[sourceRow * w + c]; + } + return out; + } + + /** Recompute world extents after surfScale changes without rebuilding source data. */ + private void refreshWorldExtentsAndLodMetadata() { + recomputeBaseWorldExtents(); + + if (activeHeightField != null) { + currentLodSurfScaleX = baseWorldWidth / activeHeightField.width(); + currentLodSurfScaleZ = baseWorldDepth / activeHeightField.height(); + } + if (surfPlot != null) { + surfPlot.setTranslateX(-baseWorldWidth / 2.0); + surfPlot.setTranslateZ(-baseWorldDepth / 2.0); + } + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + tiledSurfaceRenderer.forceUpdate(); + } else if (lodManager != null && lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { + lodManager.setPyramid( + LodManager.fromHeightFields(lodProcessedLevels), + baseWorldWidth, + baseWorldDepth, + activeLodIndex + ); + lodManager.forceUpdate(); + } + } + + /** + * Returns the long-axis world extent used for image-backed surfaces at the default + * surface scale. Image aspect ratio determines the other axis. + */ + public double getNominalImageWorldExtent() { + return nominalImageWorldExtent; + } + + /** + * Sets the long-axis world extent used for image-backed surfaces at the default + * surface scale. This changes physical display size, not source or LOD resolution. + */ + public void setNominalImageWorldExtent(double nominalImageWorldExtent) { + if (!(nominalImageWorldExtent > 0.0) || !Double.isFinite(nominalImageWorldExtent)) { + throw new IllegalArgumentException("nominalImageWorldExtent must be finite and > 0"); + } + if (Double.compare(this.nominalImageWorldExtent, nominalImageWorldExtent) == 0) return; + this.nominalImageWorldExtent = nominalImageWorldExtent; + if (imageBackedSurface && processingSourceHeightField != null) { + refreshWorldExtentsAndLodMetadata(); + updateTheMesh(); + } + } + + public SurfaceRowOrientation getSurfaceRowOrientation() { + return surfaceRowOrientation.get(); + } + + public void setSurfaceRowOrientation(SurfaceRowOrientation orientation) { + surfaceRowOrientation.set(orientation != null + ? orientation + : SurfaceRowOrientation.FIRST_ROW_NEAR); + } + + public ObjectProperty surfaceRowOrientationProperty() { + return surfaceRowOrientation; + } + + private void setSourceDefaultRowOrientation(SurfaceRowOrientation orientation) { + suppressRowOrientationRefresh = true; + try { + setSurfaceRowOrientation(orientation); + } finally { + suppressRowOrientationRefresh = false; + } + } + + private void applySurfaceRowOrientationToRenderers() { + SurfaceRowOrientation orientation = getSurfaceRowOrientation(); + if (surfPlot != null) surfPlot.setRowOrientation(orientation); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setRowOrientation(orientation); + } + + public SurfaceHeightOrientation getSurfaceHeightOrientation() { + return surfaceHeightOrientation.get(); + } + + public void setSurfaceHeightOrientation(SurfaceHeightOrientation orientation) { + surfaceHeightOrientation.set(orientation != null + ? orientation + : SurfaceHeightOrientation.HIGH_VALUES_UP); + } + + public ObjectProperty surfaceHeightOrientationProperty() { + return surfaceHeightOrientation; + } + + private void applySurfaceHeightOrientationToRenderers() { + SurfaceHeightOrientation orientation = getSurfaceHeightOrientation(); + if (surfPlot != null) surfPlot.setHeightOrientation(orientation); + if (tiledSurfaceRenderer != null) tiledSurfaceRenderer.setHeightOrientation(orientation); + } + + private int orientRowIndex(int logicalRow, int rowCount) { + if (rowCount <= 0) return 0; + int clamped = Math.max(0, Math.min(logicalRow, rowCount - 1)); + return getSurfaceRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR + ? rowCount - 1 - clamped + : clamped; + } + + private double orientRowCoordinate(double logicalRow, int rowCount) { + if (rowCount <= 1) return 0.0; + double clamped = Math.max(0.0, Math.min(logicalRow, rowCount - 1.0)); + return getSurfaceRowOrientation() == SurfaceRowOrientation.FIRST_ROW_FAR + ? (rowCount - 1.0) - clamped + : clamped; + } + + public boolean isTiledHeightFieldRenderingEnabled() { + return tiledHeightFieldRenderingEnabled; + } + + public void setTiledHeightFieldRenderingEnabled(boolean enabled) { + if (this.tiledHeightFieldRenderingEnabled == enabled) return; + this.tiledHeightFieldRenderingEnabled = enabled; + if (lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { + if (enabled && shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + } else if (lodManager != null) { + activeLodIndex = -1; + activeHeightField = null; + lodManager.setPyramid( + LodManager.fromHeightFields(lodProcessedLevels), + baseWorldWidth, baseWorldDepth, -1); + lodManager.forceUpdate(); + } + updateTheMesh(); + } + } + + public int getTileCellsL0() { + return tileCellsL0; + } + + + public TiledLodManager.Config getTiledLodConfigCopy() { + return tiledSurfaceRenderer != null + ? tiledSurfaceRenderer.getLodConfigCopy() + : new TiledLodManager.Config(); + } + + public void setTiledLodConfig(TiledLodManager.Config config) { + if (tiledSurfaceRenderer == null || config == null) return; + tiledSurfaceRenderer.setLodConfig(config); + } + + public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { + return tiledSurfaceRenderer != null + ? tiledSurfaceRenderer.getLodStatistics() + : new TiledSurfaceRenderer.LodStatistics( + 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0, 0); + } + + public void setTileCellsL0(int tileCellsL0) { + if (tileCellsL0 < 16) { + throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + } + if (this.tileCellsL0 == tileCellsL0) return; + this.tileCellsL0 = tileCellsL0; + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + updateTheMesh(); + } + } + + public int getMaxRenderResolution() { + return maxRenderResolution; + } + + public void setMaxRenderResolution(int maxRenderResolution) { + if (maxRenderResolution <= 0) { + throw new IllegalArgumentException("maxRenderResolution must be > 0"); + } + if (maxRenderResolution < minRenderResolution) { + throw new IllegalArgumentException("maxRenderResolution must be >= minRenderResolution"); + } + if (this.maxRenderResolution == maxRenderResolution) return; + this.maxRenderResolution = maxRenderResolution; + if (imageBackedSurface && lastImage != null && imageSourceWidth > 0 && imageSourceHeight > 0) { + PixelReader reader = lastImage.getPixelReader(); + if (reader != null) { + processingSourceHeightField = buildImageRawL0(reader, + imageSourceStartX, imageSourceStartY, + imageSourceWidth, imageSourceHeight, maxRenderResolution); + } + } + rebuildProcessedGridAndRefresh(); + } + + public int getMinRenderResolution() { + return minRenderResolution; + } + + public void setMinRenderResolution(int minRenderResolution) { + if (minRenderResolution <= 0) { + throw new IllegalArgumentException("minRenderResolution must be > 0"); + } + if (minRenderResolution > maxRenderResolution) { + throw new IllegalArgumentException("minRenderResolution must be <= maxRenderResolution"); + } + if (this.minRenderResolution == minRenderResolution) return; + this.minRenderResolution = minRenderResolution; + rebuildProcessedGridAndRefresh(); + } + + private boolean shouldUseTiledHeightFieldRenderer() { + return tiledHeightFieldRenderingEnabled + && imageBackedSurface + && tiledSurfaceRenderer != null + && lodProcessedLevels != null + && !lodProcessedLevels.isEmpty() + && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; + } + + public boolean isTiledHeightFieldRendererActive() { + return shouldUseTiledHeightFieldRenderer() + && surfaceRender + && tiledSurfaceRenderer.isVisible(); + } + + private void requestLodUpdate(LodManager.UpdateReason reason) { + if (shouldUseTiledHeightFieldRenderer()) { + tiledSurfaceRenderer.requestUpdate(reason); + } else if (lodManager != null) { + lodManager.requestUpdate(reason); + } + } + + private void forceLodUpdate() { + if (shouldUseTiledHeightFieldRenderer()) { + tiledSurfaceRenderer.forceUpdate(); + } else if (lodManager != null) { + lodManager.forceUpdate(); + } + } + + private void configureTiledAppearance() { + if (tiledSurfaceRenderer == null) return; + if (surfPlot != null) { + tiledSurfaceRenderer.setDrawMode(surfPlot.getDrawMode()); + tiledSurfaceRenderer.setCullFace(surfPlot.getCullFace()); + if (surfPlot.getMaterial() instanceof PhongMaterial material) { + tiledSurfaceRenderer.setSpecularColor(material.getSpecularColor()); + } + } + tiledSurfaceRenderer.setYScale(yScale); + tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); + + if (colorationMethod == COLORATION.COLOR_BY_IMAGE && lastImage != null) { + tiledSurfaceRenderer.setColorByImage(lastImage); + } else if (colorationMethod == COLORATION.COLOR_BY_FEATURE && !lodProcessedLevels.isEmpty()) { + float[] minMax = lodProcessedLevels.get(0).minMax(); + double minColorValue = minMax[0] * yScale; + double maxColorValue = minMax[1] * yScale; + if (!(maxColorValue > minColorValue)) { + double pad = Math.max(1.0e-9, Math.abs(minColorValue) * 1.0e-9); + minColorValue -= pad; + maxColorValue += pad; + } + tiledSurfaceRenderer.setColorByHeight( + TOTAL_COLORS, minColorValue, maxColorValue); + } + } + + private void configureTiledPyramid() { + if (!shouldUseTiledHeightFieldRenderer()) return; + activeLodIndex = 0; + activeHeightField = lodProcessedLevels.get(0); + xWidth = activeHeightField.width(); + zWidth = activeHeightField.height(); + currentLodSurfScaleX = baseWorldWidth / xWidth; + currentLodSurfScaleZ = baseWorldDepth / zWidth; + + tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); + tiledSurfaceRenderer.setPyramid( + lodProcessedLevels, baseWorldWidth, baseWorldDepth, yScale); + configureTiledAppearance(); + tiledSurfaceRenderer.forceUpdate(); + syncGuiControls(); + } + private void rebuildProcessedGridAndRefresh() { - if (originalGrid == null || originalGrid.isEmpty()) return; - List> g = deepCopyGrid(originalGrid); - if (smoothingEnabled) { - g = SurfaceUtils.smooth(g, smoothingMethod, gaussianSigma, smoothingRadius); + // Process raw L0 once, then derive all lower LODs from that processed L0. + // Image-backed surfaces already retain a capped raw L0; boxed-grid-backed + // surfaces retain their source resolution and are capped here. + // + // Boxed-grid-backed surfaces continue to populate + // originalGrid and are converted lazily here for compatibility. + if (processingSourceHeightField == null) { + if (originalGrid == null || originalGrid.isEmpty()) return; + processingSourceHeightField = SurfaceUtils.toHeightField(originalGrid); } - if (toneEnabled) { - g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); + + HeightField l0Raw = imageBackedSurface + ? processingSourceHeightField + : SurfaceUtils.buildL0Raw(processingSourceHeightField, maxRenderResolution); + + SurfaceUtils.Smoothing sm = smoothingEnabled + ? smoothingMethod + : SurfaceUtils.Smoothing.NONE; + SurfaceUtils.ToneMap tm = toneEnabled + ? toneOperator + : SurfaceUtils.ToneMap.NONE; + + HeightField l0Processed = SurfaceUtils.processL0( + l0Raw, + sm, + smoothingRadius, + 1, + gaussianSigma, + tm, + toneParam + ); + + lodProcessedLevels = imageBackedSurface + ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) + : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); + + if (imageBackedSurface) { + StringBuilder lodSummary = new StringBuilder("Hypersurface tiled LOD pyramid: "); + for (int i = 0; i < lodProcessedLevels.size(); i++) { + if (i > 0) lodSummary.append(", "); + HeightField level = lodProcessedLevels.get(i); + lodSummary.append('L').append(i).append('=') + .append(level.width()).append('x').append(level.height()); + } + if (tiledSurfaceRenderer == null || tiledSurfaceRenderer.isVerboseDiagnosticsEnabled()) { + System.out.println(lodSummary); + } } - dataGrid.clear(); - dataGrid.addAll(g); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); + + // Rendering resolution and physical world size are intentionally independent. + // Image-backed sources use a nominal long-axis world extent with aspect ratio + // preserved; data-grid-backed sources retain the historical dimension*scale rule. + recomputeBaseWorldExtents(); + + // A new pyramid invalidates the old active HeightField even when the numerical + // LOD index happens to be identical. + activeLodIndex = -1; + activeHeightField = null; + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + updateTheMesh(); + } else if (lodManager != null) { + lodManager.setPyramid( + LodManager.fromHeightFields(lodProcessedLevels), + baseWorldWidth, + baseWorldDepth, + -1 + ); + lodManager.forceUpdate(); + } else { + applyActiveLodIndex(0); + } + + updateView(true); + } + + /** + * Apply the given LOD index from the current processed pyramid. + * Invoked by LodManager when it selects a new level. + */ + private void applyActiveLodIndex(int idx) { + if (lodProcessedLevels == null || lodProcessedLevels.isEmpty()) return; + if (idx < 0) idx = 0; + if (idx >= lodProcessedLevels.size()) idx = lodProcessedLevels.size() - 1; + + HeightField selected = lodProcessedLevels.get(idx); + if (idx == activeLodIndex && activeHeightField == selected) return; + + activeLodIndex = idx; + activeHeightField = selected; + + // xWidth/zWidth continue to describe the currently rendered mesh dimensions, + // while dataGrid/originalGrid retain full source/working data. + xWidth = activeHeightField.width(); + zWidth = activeHeightField.height(); + + currentLodSurfScaleX = baseWorldWidth / xWidth; + currentLodSurfScaleZ = baseWorldDepth / zWidth; + + System.out.println("Applying Hypersurface LOD L" + activeLodIndex + + ": " + xWidth + "x" + zWidth + + ", cellScale=" + currentLodSurfScaleX + "x" + currentLodSurfScaleZ); + + if (surfPlot != null) { + surfPlot.setTranslateX(-baseWorldWidth / 2.0); + surfPlot.setTranslateZ(-baseWorldDepth / 2.0); + } + syncGuiControls(); updateTheMesh(); - updateView(true); } /** @@ -2119,10 +3175,10 @@ private Optional tryGetSourceRowIndex(GraphNode node) { 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, zWidth - 1))); + int clamped = Math.max(0, Math.min(rowIndex, Math.max(0, getSourceHeight() - 1))); paintSingleColor(Color.TRANSPARENT); - // Crosshair expects world coordinates; Z increases with rows - Point3D center = new Point3D((xWidth * surfScale) / 2.0, 0, clamped * surfScale); + // Source row coordinates remain stable even when the active render LOD changes. + Point3D center = new Point3D(getWorldWidth() / 2.0, 0, clamped * surfScale); illuminateCrosshair(center); }); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java new file mode 100644 index 00000000..dbc8b00f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java @@ -0,0 +1,376 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.geometry.Point3D; +import javafx.scene.Node; +import javafx.scene.PerspectiveCamera; +import javafx.scene.SubScene; +import javafx.util.Duration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.IntConsumer; + +/** + * Screen-space Level-of-Detail (LOD) manager for a heightfield surface. + * + *

Responsibilities: + *

    + *
  • Compute a pixels-per-cell estimate using camera projection + view-space depth.
  • + *
  • Select an LOD index using hysteresis (low/high thresholds).
  • + *
  • Schedule evaluations using a combined leading throttle + trailing debounce policy.
  • + *
+ * + *

This class does not rebuild meshes. It notifies a listener when a new LOD is selected.

+ */ +public final class LodManager { + + private static final Logger LOG = LoggerFactory.getLogger(LodManager.class); + + public enum UpdateReason { + DRAG, + SCROLL, + RESIZE, + DATA_CHANGED, + OTHER + } + + /** + * Minimal per-LOD metadata required for screen-space selection. + * (The LodManager does not need the HeightField data itself.) + */ + public static final class LodLevel { + public final int width; + public final int height; + + public LodLevel(int width, int height) { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("LOD level dimensions must be > 0"); + } + this.width = width; + this.height = height; + } + + @Override + public String toString() { + return "LodLevel{" + width + "x" + height + "}"; + } + } + + /** + * Configuration for screen-space LOD selection and scheduling. + */ + public static final class LodConfig { + /** Target pixels per cell used for "initial pick" (not hysteresis). */ + public double targetPixelsPerCell = 1.5; + /** Switch to a coarser LOD when pixelsPerCell drops below this. */ + public double lowThreshold = 1.0; + /** Switch to a finer LOD when pixelsPerCell rises above this. */ + public double highThreshold = 2.0; + + /** Leading-edge throttle (ms): during continuous interaction, evaluate at most this often. */ + public long throttleMs = 75; + /** Trailing-edge debounce (ms): always perform a final evaluation after input settles. */ + public long debounceMs = 75; + + /** Minimum valid camera depth to avoid divide-by-zero. */ + public double minDepth = 1e-3; + + /** If true, uses the smaller of X/Z cell sizes for a conservative pixels-per-cell. */ + public boolean conservativeCell = true; + + public LodConfig() { } + + public LodConfig copy() { + LodConfig c = new LodConfig(); + c.targetPixelsPerCell = targetPixelsPerCell; + c.lowThreshold = lowThreshold; + c.highThreshold = highThreshold; + c.throttleMs = throttleMs; + c.debounceMs = debounceMs; + c.minDepth = minDepth; + c.conservativeCell = conservativeCell; + return c; + } + } + + private final PerspectiveCamera camera; + private final SubScene subScene; + private final Node surfaceNode; + + private LodConfig config = new LodConfig(); + + // LOD metadata + surface world extents (constant across LODs) + private List levels = List.of(); + private int activeIndex = -1; + private double baseWorldWidth = Double.NaN; + private double baseWorldDepth = Double.NaN; + + private IntConsumer onLodSelected = null; + + // Scheduling + private long lastEvalNanos = 0L; + private final PauseTransition debounceTimer; + + // Resize listeners for automatic LOD re-eval + private final javafx.beans.value.ChangeListener resizeListener = (obs, ov, nv) -> requestUpdate(UpdateReason.RESIZE); + + public LodManager(PerspectiveCamera camera, SubScene subScene, Node surfaceNode) { + this.camera = Objects.requireNonNull(camera, "camera"); + this.subScene = Objects.requireNonNull(subScene, "subScene"); + this.surfaceNode = Objects.requireNonNull(surfaceNode, "surfaceNode"); + + debounceTimer = new PauseTransition(Duration.millis(config.debounceMs)); + debounceTimer.setOnFinished(e -> evaluateAndNotify(false)); + + // Observe viewport changes directly (pane can also call requestUpdate explicitly; both are fine) + this.subScene.widthProperty().addListener(resizeListener); + this.subScene.heightProperty().addListener(resizeListener); + } + + /** + * Update configuration. Takes effect immediately for subsequent evaluations. + */ + public void setConfig(LodConfig cfg) { + Objects.requireNonNull(cfg, "cfg"); + this.config = cfg.copy(); + // Update debounce timer duration immediately + runOnFx(() -> debounceTimer.setDuration(Duration.millis(this.config.debounceMs))); + } + + public LodConfig getConfigCopy() { + return config.copy(); + } + + /** + * Provide the current LOD pyramid metadata and the constant world extents. + * + * @param levels ordered LOD levels: index 0 is finest (L0), increasing is coarser + * @param baseWorldWidth constant surface width in surfaceNode local world units + * @param baseWorldDepth constant surface depth in surfaceNode local world units + * @param activeIndex current active index (or -1 if unknown) + */ + public void setPyramid(List levels, double baseWorldWidth, double baseWorldDepth, int activeIndex) { + Objects.requireNonNull(levels, "levels"); + if (levels.isEmpty()) throw new IllegalArgumentException("levels must not be empty"); + if (!(baseWorldWidth > 0.0) || !(baseWorldDepth > 0.0)) { + throw new IllegalArgumentException("baseWorldWidth/baseWorldDepth must be > 0"); + } + for (LodLevel l : levels) Objects.requireNonNull(l, "levels contains null"); + + // Copy to prevent external mutation surprises + List copy = new ArrayList<>(levels); + + this.levels = List.copyOf(copy); + this.baseWorldWidth = baseWorldWidth; + this.baseWorldDepth = baseWorldDepth; + this.activeIndex = clamp(activeIndex, -1, this.levels.size() - 1); + + System.out.println("Hypersurface LOD pyramid configured: levels=" + formatLevels(this.levels) + + ", world=" + baseWorldWidth + "x" + baseWorldDepth + + ", activeIndex=" + this.activeIndex); + } + + /** + * Convenience helper if you're holding HeightField levels: convert to LodLevels. + */ + public static List fromHeightFields(List hfs) { + Objects.requireNonNull(hfs, "hfs"); + ArrayList out = new ArrayList<>(hfs.size()); + for (HeightField hf : hfs) { + Objects.requireNonNull(hf, "hfs contains null"); + out.add(new LodLevel(hf.width(), hf.height())); + } + return out; + } + + public void setOnLodSelected(IntConsumer cb) { + this.onLodSelected = cb; + } + + public int getActiveIndex() { + return activeIndex; + } + + /** + * Request an LOD evaluation. Uses combined throttle + debounce policy. + * Safe to call from any thread. + */ + public void requestUpdate(UpdateReason reason) { + runOnFx(() -> { + // Always schedule a trailing evaluation + debounceTimer.stop(); + debounceTimer.setDuration(Duration.millis(config.debounceMs)); + debounceTimer.playFromStart(); + + // Leading throttle for responsiveness during continuous interaction + long now = System.nanoTime(); + long throttleNanos = Math.max(0L, config.throttleMs) * 1_000_000L; + if (throttleNanos == 0L || (now - lastEvalNanos) >= throttleNanos) { + evaluateAndNotify(false); + } + }); + } + + /** + * Force an immediate evaluation (no throttle). This bypasses scheduling only; + * it does not bypass LOD hysteresis or repick from the target threshold. Safe to call from any thread. + */ + public void forceUpdate() { + runOnFx(() -> { + debounceTimer.stop(); + evaluateAndNotify(true); + }); + } + + /** + * Remove listeners and stop internal timers. + */ + public void dispose() { + runOnFx(() -> { + debounceTimer.stop(); + subScene.widthProperty().removeListener(resizeListener); + subScene.heightProperty().removeListener(resizeListener); + }); + } + + // ============================================================ + // Core selection logic + // ============================================================ + + private void evaluateAndNotify(boolean force) { + if (levels == null || levels.isEmpty()) return; + if (!Double.isFinite(baseWorldWidth) || !Double.isFinite(baseWorldDepth)) return; + if (camera == null || subScene == null || surfaceNode == null) return; + + int next = chooseIndexScreenSpace(force); + if (next < 0) return; + + lastEvalNanos = System.nanoTime(); + + boolean changed = next != activeIndex; + if (force || changed) { + logEvaluation(next, force, changed); + } + + if (changed) { + activeIndex = next; + if (onLodSelected != null) { + onLodSelected.accept(next); + } + } + } + + private int chooseIndexScreenSpace(boolean force) { + double d = computeDepthToSurfaceCenter(); + if (!(d > 0.0)) return (activeIndex >= 0 ? activeIndex : 0); + d = Math.max(d, config.minDepth); + + double fovY = Math.toRadians(camera.getFieldOfView()); + double Hpx = Math.max(1.0, subScene.getHeight()); + double denom = 2.0 * d * Math.tan(fovY / 2.0); + if (!(denom > 1e-12)) return (activeIndex >= 0 ? activeIndex : 0); + + // If no current index, pick based on target threshold (finest meeting target) + if (activeIndex < 0) { + int best = levels.size() - 1; // default to coarsest + for (int i = 0; i < levels.size(); i++) { + double ppc = pixelsPerCell(levels.get(i), Hpx, denom); + if (ppc >= config.targetPixelsPerCell) { + best = i; + break; // levels ordered fine->coarse, so first meeting target is best + } + } + return best; + } + + int cur = clamp(activeIndex, 0, levels.size() - 1); + double curPpc = pixelsPerCell(levels.get(cur), Hpx, denom); + + if (curPpc < config.lowThreshold && cur < levels.size() - 1) { + return cur + 1; // go coarser + } + if (curPpc > config.highThreshold && cur > 0) { + return cur - 1; // go finer + } + return cur; + } + + private double pixelsPerCell(LodLevel lvl, double Hpx, double denom) { + // Cell size in world units for this LOD, preserving constant world extents + double cellX = baseWorldWidth / lvl.width; + double cellZ = baseWorldDepth / lvl.height; + double cell = config.conservativeCell ? Math.min(cellX, cellZ) : 0.5 * (cellX + cellZ); + return (cell * Hpx) / denom; + } + + /** + * Computes view-space depth (|z| in camera local coordinates) from camera to the surface center. + * + *

Assumes surfaceNode local space uses world units (i.e., the mesh is built with x*scale, z*scale). + * The center point is defined as (baseWorldWidth/2, 0, baseWorldDepth/2) in surfaceNode local coords.

+ */ + private double computeDepthToSurfaceCenter() { + double cx = baseWorldWidth * 0.5; + double cz = baseWorldDepth * 0.5; + + Point3D scenePt = surfaceNode.localToScene(cx, 0.0, cz); + Point3D camPt = camera.sceneToLocal(scenePt); + return Math.abs(camPt.getZ()); + } + + + private void logEvaluation(int selectedIndex, boolean force, boolean changed) { + double d = computeDepthToSurfaceCenter(); + double fovY = Math.toRadians(camera.getFieldOfView()); + double hpx = Math.max(1.0, subScene.getHeight()); + double denom = 2.0 * Math.max(d, config.minDepth) * Math.tan(fovY / 2.0); + + StringBuilder ppc = new StringBuilder(); + for (int i = 0; i < levels.size(); i++) { + if (i > 0) ppc.append(", "); + double value = denom > 1e-12 ? pixelsPerCell(levels.get(i), hpx, denom) : Double.NaN; + ppc.append("L").append(i) + .append("=").append(levels.get(i).width).append("x").append(levels.get(i).height) + .append("@").append(String.format(java.util.Locale.ROOT, "%.3f", value)).append("px/cell"); + } + + System.out.println("Hypersurface LOD evaluation: depth=" + + String.format(java.util.Locale.ROOT, "%.3f", d) + + ", viewportHeight=" + String.format(java.util.Locale.ROOT, "%.1f", hpx) + + ", selected=L" + selectedIndex + + ", previous=L" + activeIndex + + ", force=" + force + + ", changed=" + changed + + ", " + ppc); + } + + private static String formatLevels(List levels) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < levels.size(); i++) { + if (i > 0) sb.append(", "); + LodLevel l = levels.get(i); + sb.append("L").append(i).append("=").append(l.width).append("x").append(l.height); + } + return sb.append(']').toString(); + } + + // ============================================================ + // Helpers + // ============================================================ + + private static int clamp(int v, int lo, int hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; + } + + private static void runOnFx(Runnable r) { + if (Platform.isFxApplicationThread()) r.run(); + else Platform.runLater(r); + } +} \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java new file mode 100644 index 00000000..992b28d4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java @@ -0,0 +1,42 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * One requested tile LOD transition, ordered by rendering priority. + * + *

Lower {@code priorityClass} values are applied first. Within the same + * class, tiles nearer the viewport center are preferred, followed by nearer + * tiles.

+ */ +public record LodTransition( + int tileId, + int fromLod, + int toLod, + int priorityClass, + double screenDistance, + double depth, + double pixelsPerCell +) implements Comparable { + + public boolean isInitial() { + return fromLod < 0; + } + + public boolean isCoarsening() { + return fromLod >= 0 && toLod > fromLod; + } + + public boolean isRefining() { + return fromLod >= 0 && toLod < fromLod; + } + + @Override + public int compareTo(LodTransition other) { + int c = Integer.compare(priorityClass, other.priorityClass); + if (c != 0) return c; + c = Double.compare(screenDistance, other.screenDistance); + if (c != 0) return c; + c = Double.compare(depth, other.depth); + if (c != 0) return c; + return Integer.compare(tileId, other.tileId); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java new file mode 100644 index 00000000..58cab0cd --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java @@ -0,0 +1,51 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * Controls how scalar surface height values map onto the JavaFX Y axis. + * + *

JavaFX uses a screen-oriented coordinate convention where positive Y points + * downward. {@link #HIGH_VALUES_UP} therefore maps increasing scalar values toward + * Y-, which is the intuitive default for elevations, intensity surfaces, and most + * ordinary height maps.

+ * + *

{@link #HIGH_VALUES_DOWN} preserves the historical Trinity mapping where + * increasing scalar values move toward Y+.

+ */ +public enum SurfaceHeightOrientation { + HIGH_VALUES_UP("High Values Up", -1.0), + HIGH_VALUES_DOWN("High Values Down", 1.0); + + private final String displayName; + private final double yDirection; + + SurfaceHeightOrientation(String displayName, double yDirection) { + this.displayName = displayName; + this.yDirection = yDirection; + } + + /** + * Converts a source scalar value into its world-space Y coordinate using the + * supplied magnitude scale. + */ + public double toWorldY(double value, double yScale) { + return value * yScale * yDirection; + } + + /** + * Converts an already-scaled world Y coordinate back to an orientation-neutral + * signed height. This is useful for color mappings that should not reverse when + * the visual height orientation changes. + */ + public double toLogicalHeight(double worldY) { + return worldY * yDirection; + } + + public double yDirection() { + return yDirection; + } + + @Override + public String toString() { + return displayName; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java new file mode 100644 index 00000000..a9275df1 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java @@ -0,0 +1,30 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * Controls how source rows map onto the Hypersurface Z axis. + * + *

{@link #FIRST_ROW_NEAR} preserves the historical Trinity convention: source + * row zero is placed at the Z- / near edge of the surface. This is useful for + * ordered samples where the first/oldest sample should appear closest to the + * beginning of the surface.

+ * + *

{@link #FIRST_ROW_FAR} reverses the row-to-Z mapping: source row zero is + * placed at the Z+ / far edge. This is the intuitive default for ordinary 2D + * imagery viewed in perspective because the bottom of the image is then closest + * to the viewer.

+ */ +public enum SurfaceRowOrientation { + FIRST_ROW_NEAR("First Row Near"), + FIRST_ROW_FAR("First Row Far"); + + private final String displayName; + + SurfaceRowOrientation(String displayName) { + this.displayName = displayName; + } + + @Override + public String toString() { + return displayName; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java index f1f3ea52..9e652e87 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -3,10 +3,15 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Objects; /** * Utility helpers for sampling, smoothing, and tone-mapping height grids * used by Hypersurface rendering. + * + *

This class preserves the existing List> API for compatibility, + * and adds a parallel primitive API using {@link HeightField} to eliminate boxing, + * deep-copy overhead, and excessive GC for large grids.

*/ public final class SurfaceUtils { private SurfaceUtils() { @@ -20,6 +25,10 @@ public enum Smoothing {NONE, BOX, GAUSSIAN, MEDIAN} public enum ToneMap {NONE, CLAMP_01, NORMALIZE_01, LOG1P, GAMMA, ZSCORE} + // =================================================================== + // List> API (existing behavior preserved) + // =================================================================== + // ============================ Sampling ============================== public static double sample(List> grid, double x, double y, Interpolation mode) { @@ -111,8 +120,7 @@ public static double sampleBicubicSafe(List> g, double x, double y) // =========================== Smoothing ============================== /** - * ***New:*** Convenience wrapper to match your call site - * {@code SurfaceUtils.smooth(g, sm, sigma, radius)}. + * Convenience wrapper to match call site {@code smooth(g, sm, sigma, radius)}. * One iteration; GAUSSIAN uses {@code sigma}; other methods ignore it. */ public static List> smooth( @@ -215,7 +223,7 @@ public static void smoothInPlace( // =========================== Tone mapping =========================== /** - * ***New:*** Alias to match your call site {@code toneMapGrid(g, tm, param)}. + * Alias to match call site {@code toneMapGrid(g, tm, param)}. */ public static List> toneMapGrid( List> grid, @@ -428,4 +436,552 @@ private static void replaceContents(List> target, List target.clear(); target.addAll(src); } -} + + // =================================================================== + // HeightField (primitive) API (new functionality) + // =================================================================== + + // ------------------------- + // Dimension helpers + // ------------------------- + + /** + * Compute L0 dimensions (width,height) from full-res source while preserving aspect ratio + * and clamping the larger side to maxRender. + * + * @param fullW source width (samples) + * @param fullH source height (samples) + * @param maxRender maximum allowed dimension (e.g. 2048) + * @return int[2] {l0Width, l0Height} + */ + public static int[] computeL0Dimensions(int fullW, int fullH, int maxRender) { + if (fullW <= 0 || fullH <= 0 || maxRender <= 0) throw new IllegalArgumentException("Invalid dimensions"); + if (fullW <= maxRender && fullH <= maxRender) return new int[]{fullW, fullH}; + double scale = Math.min((double) maxRender / fullW, (double) maxRender / fullH); + int w = Math.max(1, (int) Math.round(fullW * scale)); + int h = Math.max(1, (int) Math.round(fullH * scale)); + return new int[]{w, h}; + } + + // ------------------------- + // Conversion helpers + // ------------------------- + + /** + * Convert a boxed grid to a primitive HeightField (float-backed). + * Values are cast to float. + */ + public static HeightField toHeightField(List> grid) { + if (isEmpty(grid)) return HeightField.allocate(1, 1); + int h = height(grid), w = width(grid); + float[] out = new float[Math.multiplyExact(w, h)]; + int idx = 0; + for (int y = 0; y < h; y++) { + List row = grid.get(y); + for (int x = 0; x < w; x++) out[idx++] = row.get(x).floatValue(); + } + return new HeightField(w, h, out); + } + + /** + * Convert a primitive HeightField to a boxed grid. + */ + public static List> toGrid(HeightField hf) { + Objects.requireNonNull(hf, "hf"); + int w = hf.width(), h = hf.height(); + float[] data = hf.data(); + List> out = new ArrayList<>(h); + int idx = 0; + for (int y = 0; y < h; y++) { + ArrayList row = new ArrayList<>(w); + for (int x = 0; x < w; x++) row.add((double) data[idx++]); + out.add(row); + } + return out; + } + + // ------------------------- + // Sampling (HeightField) + // ------------------------- + + /** + * Sample a HeightField at (x,y) in grid coordinates where integer coordinates + * correspond to exact samples. + * + * @return sampled value as double (to match existing List-grid sample API) + */ + public static double sample(HeightField hf, double x, double y, Interpolation mode) { + Objects.requireNonNull(hf, "hf"); + int w = hf.width(), h = hf.height(); + x = clamp(x, 0.0, w - 1.0); + y = clamp(y, 0.0, h - 1.0); + return switch (mode) { + case NEAREST -> sampleNearest(hf, x, y); + case BILINEAR -> sampleBilinear(hf, x, y); + case BICUBIC -> sampleBicubicSafe(hf, x, y); + }; + } + + /** + * Default HeightField sample: bilinear. + */ + public static double sample(HeightField hf, double x, double y) { + return sample(hf, x, y, Interpolation.BILINEAR); + } + + private static double sampleNearest(HeightField hf, double x, double y) { + int w = hf.width(), h = hf.height(); + int ix = (int) Math.round(clamp(x, 0, w - 1)); + int iy = (int) Math.round(clamp(y, 0, h - 1)); + return hf.data()[iy * w + ix]; + } + + public static double sampleBilinear(HeightField hf, double x, double y) { + int w = hf.width(), h = hf.height(); + + int x0 = (int) Math.floor(x); + int y0 = (int) Math.floor(y); + int x1 = x0 + 1; + int y1 = y0 + 1; + + double tx = x - x0; + double ty = y - y0; + + x0 = clamp(x0, 0, w - 1); + x1 = clamp(x1, 0, w - 1); + y0 = clamp(y0, 0, h - 1); + y1 = clamp(y1, 0, h - 1); + if (x0 == x1) tx = 0.0; + if (y0 == y1) ty = 0.0; + + float[] d = hf.data(); + double c00 = d[y0 * w + x0]; + double c10 = d[y0 * w + x1]; + double c01 = d[y1 * w + x0]; + double c11 = d[y1 * w + x1]; + + double cx0 = lerp(c00, c10, tx); + double cx1 = lerp(c01, c11, tx); + return lerp(cx0, cx1, ty); + } + + public static double sampleBicubicSafe(HeightField hf, double x, double y) { + int w = hf.width(), h = hf.height(); + int ix = (int) Math.floor(x); + int iy = (int) Math.floor(y); + if (ix < 1 || ix > w - 3 || iy < 1 || iy > h - 3) { + return sampleBilinear(hf, x, y); + } + double tx = x - ix, ty = y - iy; + + float[] d = hf.data(); + double[] col = new double[4]; + for (int row = -1; row <= 2; row++) { + int yy = (iy + row) * w; + double p0 = d[yy + (ix - 1)]; + double p1 = d[yy + (ix)]; + double p2 = d[yy + (ix + 1)]; + double p3 = d[yy + (ix + 2)]; + col[row + 1] = cubicCatmullRom(p0, p1, p2, p3, tx); + } + return cubicCatmullRom(col[0], col[1], col[2], col[3], ty); + } + + // ------------------------- + // Resampling / Pyramid + // ------------------------- + + /** + * Resample the source heightfield to target dimensions using bilinear filtering. + * Destination samples are aligned by mapping destination centers to source coordinates. + */ + public static HeightField resample(HeightField src, int targetW, int targetH) { + Objects.requireNonNull(src, "src"); + if (targetW <= 0 || targetH <= 0) throw new IllegalArgumentException("Invalid target dims"); + final int sw = src.width(); + final int sh = src.height(); + final float[] sdata = src.data(); + final float[] out = new float[Math.multiplyExact(targetW, targetH)]; + + // Map dst pixel centers to src grid coords. + // gx = (x + 0.5) * (sw/targetW) - 0.5 + final double sxRatio = (double) sw / targetW; + final double syRatio = (double) sh / targetH; + + for (int y = 0; y < targetH; y++) { + double gy = (y + 0.5) * syRatio - 0.5; + gy = clamp(gy, 0.0, sh - 1.0); + int y0 = (int) Math.floor(gy); + int y1 = clamp(y0 + 1, 0, sh - 1); + double ty = gy - y0; + if (y0 == y1) ty = 0.0; + + int row0 = y0 * sw; + int row1 = y1 * sw; + + for (int x = 0; x < targetW; x++) { + double gx = (x + 0.5) * sxRatio - 0.5; + gx = clamp(gx, 0.0, sw - 1.0); + int x0 = (int) Math.floor(gx); + int x1 = clamp(x0 + 1, 0, sw - 1); + double tx = gx - x0; + if (x0 == x1) tx = 0.0; + + double c00 = sdata[row0 + x0]; + double c10 = sdata[row0 + x1]; + double c01 = sdata[row1 + x0]; + double c11 = sdata[row1 + x1]; + + double a = lerp(c00, c10, tx); + double b = lerp(c01, c11, tx); + out[y * targetW + x] = (float) lerp(a, b, ty); + } + } + + return new HeightField(targetW, targetH, out); + } + + /** + * Downsample by ~2x. Uses bilinear resample; safe for odd sizes. + */ + public static HeightField downsample2x(HeightField src) { + Objects.requireNonNull(src, "src"); + int tw = Math.max(1, src.width() / 2); + int th = Math.max(1, src.height() / 2); + return resample(src, tw, th); + } + + /** + * Build L0 raw by resampling full-res to aspect-preserving dimensions clamped by maxRender. + */ + public static HeightField buildL0Raw(HeightField fullRes, int maxRender) { + Objects.requireNonNull(fullRes, "fullRes"); + int[] dims = computeL0Dimensions(fullRes.width(), fullRes.height(), maxRender); + if (dims[0] == fullRes.width() && dims[1] == fullRes.height()) { + // processL0() does not mutate its input, so avoid an unnecessary same-size copy. + return fullRes; + } + return resample(fullRes, dims[0], dims[1]); + } + + /** + * Build a pyramid of processed levels from processed L0. + * Each subsequent level is downsampled by 2x until the next would fall below minDim + * on either axis. + * + * @return levels [L0, L1, L2, ...] + */ + public static List buildPyramid(HeightField l0Processed, int minDim) { + Objects.requireNonNull(l0Processed, "l0Processed"); + if (minDim <= 0) throw new IllegalArgumentException("minDim must be > 0"); + ArrayList levels = new ArrayList<>(); + levels.add(l0Processed); + HeightField prev = l0Processed; + while (true) { + int nextW = Math.max(1, prev.width() / 2); + int nextH = Math.max(1, prev.height() / 2); + if (Math.min(nextW, nextH) < minDim) break; + HeightField next = resample(prev, nextW, nextH); + levels.add(next); + prev = next; + } + return levels; + } + + /** + * Build exactly up to {@code maxLevels} power-of-two LOD levels from processed L0. + * This is intended for tiled rendering where the useful stopping criterion is the + * number of tile-local LOD steps rather than a global image dimension. + * + *

The method stops early only if both dimensions can no longer be reduced.

+ * + * @return levels [L0, L1, ...] with at most {@code maxLevels} entries + */ + public static List buildPyramidLevels(HeightField l0Processed, int maxLevels) { + Objects.requireNonNull(l0Processed, "l0Processed"); + if (maxLevels < 1) { + throw new IllegalArgumentException("maxLevels must be >= 1"); + } + + ArrayList levels = new ArrayList<>(maxLevels); + levels.add(l0Processed); + HeightField prev = l0Processed; + + while (levels.size() < maxLevels) { + int nextW = Math.max(1, prev.width() / 2); + int nextH = Math.max(1, prev.height() / 2); + if (nextW == prev.width() && nextH == prev.height()) break; + + HeightField next = resample(prev, nextW, nextH); + levels.add(next); + prev = next; + } + return levels; + } + + // ------------------------- + // Smoothing (HeightField) + // ------------------------- + + /** + * Smooth a HeightField and return a new HeightField. + * + *

Behavior mirrors the List-grid version: + *

    + *
  • NONE or radius<=0 or iterations<=0 returns a copy
  • + *
  • GAUSSIAN uses sigma if >0, else heuristic
  • + *
+ */ + public static HeightField smoothCopy( + HeightField hf, + Smoothing method, + int radius, + int iterations, + double sigma + ) { + Objects.requireNonNull(hf, "hf"); + if (method == null) method = Smoothing.NONE; + if (method == Smoothing.NONE || radius <= 0 || iterations <= 0) return hf.copy(); + + HeightField src = hf.copy(); + HeightField dst = HeightField.allocate(hf.width(), hf.height()); + + switch (method) { + case BOX -> { + for (int i = 0; i < iterations; i++) { + boxBlurSeparable(src, dst, radius); + HeightField tmp = src; + src = dst; + dst = tmp; + } + } + case GAUSSIAN -> { + double[] k = gaussianKernel(radius, sigma); + for (int i = 0; i < iterations; i++) { + convolveSeparable(src, dst, k); + HeightField tmp = src; + src = dst; + dst = tmp; + } + } + case MEDIAN -> { + for (int i = 0; i < iterations; i++) { + medianFilter(src, dst, radius); + HeightField tmp = src; + src = dst; + dst = tmp; + } + } + default -> { /* NONE handled above */ } + } + return src; + } + + /** + * Convenience overload: one iteration, sigma applied only for GAUSSIAN. + */ + public static HeightField smooth(HeightField hf, Smoothing method, double sigma, int radius) { + return smoothCopy(hf, method, radius, 1, sigma); + } + + /** + * Convenience overload: one iteration, heuristic sigma for GAUSSIAN. + */ + public static HeightField smooth(HeightField hf, Smoothing method, int radius) { + return smoothCopy(hf, method, radius, 1, -1.0); + } + + // HeightField smoothing internals + + private static void boxBlurSeparable(HeightField src, HeightField dst, int radius) { + double[] k = boxKernel(radius); + convolveSeparable(src, dst, k); + } + + private static void convolveSeparable(HeightField src, HeightField dst, double[] kernel) { + final int w = src.width(); + final int h = src.height(); + final int r = kernel.length / 2; + + float[] s = src.data(); + float[] tmp = new float[Math.multiplyExact(w, h)]; + float[] out = dst.data(); + + // horizontal + for (int y = 0; y < h; y++) { + int row = y * w; + for (int x = 0; x < w; x++) { + double acc = 0.0; + for (int i = -r; i <= r; i++) { + int xx = clamp(x + i, 0, w - 1); + acc += s[row + xx] * kernel[i + r]; + } + tmp[row + x] = (float) acc; + } + } + + // vertical + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + double acc = 0.0; + for (int i = -r; i <= r; i++) { + int yy = clamp(y + i, 0, h - 1); + acc += tmp[yy * w + x] * kernel[i + r]; + } + out[y * w + x] = (float) acc; + } + } + } + + private static void medianFilter(HeightField src, HeightField dst, int radius) { + final int w = src.width(); + final int h = src.height(); + final int size = (2 * radius + 1) * (2 * radius + 1); + + float[] s = src.data(); + float[] out = dst.data(); + float[] win = new float[size]; + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int idx = 0; + for (int dy = -radius; dy <= radius; dy++) { + int yy = clamp(y + dy, 0, h - 1); + int row = yy * w; + for (int dx = -radius; dx <= radius; dx++) { + int xx = clamp(x + dx, 0, w - 1); + win[idx++] = s[row + xx]; + } + } + java.util.Arrays.sort(win); + float med = (size % 2 == 1) ? win[size / 2] : (0.5f * (win[size / 2 - 1] + win[size / 2])); + out[y * w + x] = med; + } + } + } + + // ------------------------- + // Tone mapping (HeightField) + // ------------------------- + + /** + * Tone-map a HeightField and return a new HeightField. + * Semantics match {@link #toneMapCopy(List, ToneMap, double)}. + */ + public static HeightField toneMapCopy(HeightField hf, ToneMap mode, double param) { + Objects.requireNonNull(hf, "hf"); + if (mode == null) mode = ToneMap.NONE; + if (mode == ToneMap.NONE) return hf.copy(); + + final int w = hf.width(); + final int h = hf.height(); + final float[] in = hf.data(); + final float[] out = new float[Math.multiplyExact(w, h)]; + + double min = Double.POSITIVE_INFINITY, max = Double.NEGATIVE_INFINITY; + double mean = 0.0, m2 = 0.0; + int n = 0; + + if (mode == ToneMap.NORMALIZE_01 || mode == ToneMap.LOG1P || mode == ToneMap.GAMMA) { + for (float v : in) { + if (v < min) min = v; + if (v > max) max = v; + } + } else if (mode == ToneMap.ZSCORE) { + for (float vf : in) { + double v = vf; + n++; + double d = v - mean; + mean += d / n; + double d2 = v - mean; + m2 += d * d2; + } + } + + final double range = (max - min); + final double gamma = (mode == ToneMap.GAMMA && param > 0) ? param : 2.2; + + for (int i = 0; i < in.length; i++) { + double v = in[i]; + double r; + switch (mode) { + case CLAMP_01 -> r = clamp(v, 0.0, 1.0); + case NORMALIZE_01 -> { + r = (range <= 0) ? 0.0 : (v - min) / range; + r = clamp(r, 0.0, 1.0); + } + case LOG1P -> { + double shift = (min < 0) ? -min : 0.0; + double lv = Math.log1p(v + shift); + double lmax = Math.log1p(max + shift); + r = (lmax <= 0) ? 0.0 : (lv / lmax); + r = clamp(r, 0.0, 1.0); + } + case GAMMA -> { + double t = (range <= 0) ? 0.0 : (v - min) / range; + t = clamp(t, 0.0, 1.0); + r = Math.pow(t, 1.0 / gamma); + } + case ZSCORE -> { + double variance = (n > 1) ? (m2 / (n - 1)) : 0.0; + double std = (variance > 0) ? Math.sqrt(variance) : 1.0; + r = (v - mean) / std; + } + default -> r = v; + } + out[i] = (float) r; + } + + return new HeightField(w, h, out); + } + + /** + * Alias mirroring List-grid {@link #toneMapGrid(List, ToneMap, double)}. + */ + public static HeightField toneMapGrid(HeightField hf, ToneMap mode, double param) { + return toneMapCopy(hf, mode, param); + } + + // ------------------------- + // Convenience pipeline helper (Option B) + // ------------------------- + + /** + * Process L0: smoothing then tone map. Intended for "Option B" pipeline where + * L0 is processed once then downsampled into lower LODs. + * + * @param l0Raw resampled raw L0 + * @param smoothing smoothing mode + * @param radius smoothing radius + * @param iterations smoothing iterations + * @param sigma gaussian sigma (only for GAUSSIAN; <=0 uses heuristic) + * @param toneMap tone-map mode + * @param toneParam tone-map parameter (GAMMA uses this if >0) + * @return processed L0 + */ + public static HeightField processL0( + HeightField l0Raw, + Smoothing smoothing, + int radius, + int iterations, + double sigma, + ToneMap toneMap, + double toneParam + ) { + Objects.requireNonNull(l0Raw, "l0Raw"); + Smoothing effectiveSmoothing = smoothing != null ? smoothing : Smoothing.NONE; + ToneMap effectiveToneMap = toneMap != null ? toneMap : ToneMap.NONE; + boolean smoothActive = effectiveSmoothing != Smoothing.NONE && radius > 0 && iterations > 0; + boolean toneActive = effectiveToneMap != ToneMap.NONE; + + if (!smoothActive && !toneActive) { + return l0Raw.copy(); + } + if (!smoothActive) { + return toneMapCopy(l0Raw, effectiveToneMap, toneParam); + } + + HeightField smoothed = smoothCopy(l0Raw, effectiveSmoothing, radius, iterations, sigma); + return toneActive ? toneMapCopy(smoothed, effectiveToneMap, toneParam) : smoothed; + } +} \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java new file mode 100644 index 00000000..6e012c2b --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java @@ -0,0 +1,76 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +/** + * Persistent JavaFX render state for one tiled hypersurface region. + * + *

Each LOD receives its own {@link HyperSurfacePlotMesh}. Once created, that + * MeshView remains permanently associated with its TriangleMesh; switching LOD + * therefore becomes a visibility change rather than a MeshView.setMesh(...) + * operation.

+ */ +public final class TileRenderState { + + final TiledLodManager.TileSpec spec; + final int startX0; + final int endX0; + final int startZ0; + final int endZ0; + private final HyperSurfacePlotMesh[] lodViews; + + private int activeLod = -1; + private boolean tileVisible; + + TileRenderState(TiledLodManager.TileSpec spec, + int startX0, int endX0, + int startZ0, int endZ0, + int lodCount) { + this.spec = spec; + this.startX0 = startX0; + this.endX0 = endX0; + this.startZ0 = startZ0; + this.endZ0 = endZ0; + this.lodViews = new HyperSurfacePlotMesh[Math.max(1, lodCount)]; + } + + public int getTileId() { + return spec.id; + } + + public int getActiveLod() { + return activeLod; + } + + void setActiveLod(int activeLod) { + this.activeLod = activeLod; + } + + public boolean isTileVisible() { + return tileVisible; + } + + void setTileVisible(boolean tileVisible) { + this.tileVisible = tileVisible; + } + + public HyperSurfacePlotMesh getLodView(int lod) { + return lod >= 0 && lod < lodViews.length ? lodViews[lod] : null; + } + + void setLodView(int lod, HyperSurfacePlotMesh view) { + if (lod < 0 || lod >= lodViews.length) { + throw new IndexOutOfBoundsException("LOD index out of range: " + lod); + } + lodViews[lod] = view; + } + + public int getLodCount() { + return lodViews.length; + } + + void clearViews() { + for (int i = 0; i < lodViews.length; i++) { + lodViews[i] = null; + } + activeLod = -1; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java new file mode 100644 index 00000000..655df7f9 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -0,0 +1,1111 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import javafx.animation.AnimationTimer; +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.geometry.Point3D; +import javafx.scene.Node; +import javafx.scene.PerspectiveCamera; +import javafx.scene.SubScene; +import javafx.util.Duration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.PriorityQueue; +import java.util.function.Consumer; + +/** + * Camera/frustum-aware per-tile LOD selector and transition scheduler. + * + *

The hot camera-evaluation path is array-backed. Tile IDs are used as direct + * indexes into reusable primitive state arrays, avoiding HashMap/HashSet lookups, + * boxing, and per-evaluation TileDecision allocation for the normal renderer path.

+ */ +public final class TiledLodManager { + + public static final class TileSpec { + public final int id; + public final int column; + public final int row; + public final double minX; + public final double maxX; + public final double minZ; + public final double maxZ; + + public TileSpec(int id, int column, int row, + double minX, double maxX, + double minZ, double maxZ) { + this.id = id; + this.column = column; + this.row = row; + this.minX = minX; + this.maxX = maxX; + this.minZ = minZ; + this.maxZ = maxZ; + } + + public double centerX() { return (minX + maxX) * 0.5; } + public double centerZ() { return (minZ + maxZ) * 0.5; } + public double halfWidth() { return Math.max(0.0, (maxX - minX) * 0.5); } + public double halfDepth() { return Math.max(0.0, (maxZ - minZ) * 0.5); } + } + + /** + * Compatibility snapshot type. The renderer no longer requires these records; + * they are materialized only if an external compatibility listener is installed. + */ + public record TileDecision(int tileId, boolean visible, int lodIndex, + double depth, double pixelsPerCell, + double screenDistance) { } + + public record TransitionResult(boolean applied, boolean built, boolean cacheHit) { + public static TransitionResult builtResult() { + return new TransitionResult(true, true, false); + } + + public static TransitionResult cacheHitResult() { + return new TransitionResult(true, false, true); + } + + public static TransitionResult notApplied() { + return new TransitionResult(false, false, false); + } + } + + @FunctionalInterface + public interface TransitionApplier { + TransitionResult apply(LodTransition transition, boolean allowBuild); + } + + @FunctionalInterface + public interface GeometryCostProvider { + long trianglesFor(int tileId, int lodIndex); + } + + /** Immediate visibility callback using the manager's reusable primitive state. */ + @FunctionalInterface + public interface VisibilityApplier { + void apply(boolean[] visibleByTile, int tileStateSize); + } + + public static final class Config { + public double targetPixelsPerCell = 1.5; + public double lowThreshold = 1.0; + public double highThreshold = 2.0; + /** Finest LOD index allowed to be selected (0 = L0, 1 = L1, ...). */ + public int finestAllowedLod = 0; + /** Apply a second-stage aggregate triangle budget after per-tile LOD selection. */ + public boolean geometryBudgetEnabled = true; + /** Maximum target triangles across all currently visible tiles. */ + public long triangleBudget = 5_000_000L; + /** Emit detailed tiled-LOD diagnostics to stdout. Disabled by default. */ + public boolean verboseDiagnostics = false; + public long throttleMs = 75; + public long debounceMs = 75; + public double minDepth = 1e-3; + /** Expand the conservative tile bounding sphere used for frustum tests. */ + public double frustumMargin = 1.10; + /** Adjacent visible tiles may differ by at most this many LOD levels. */ + public int maxNeighborLodDelta = 1; + + /** Maximum cached/cheap LOD switches applied per JavaFX pulse while moving. */ + public int activeTransitionsPerPulse = 6; + /** Maximum transitions per pulse after camera interaction settles. */ + public int settledTransitionsPerPulse = 8; + /** Quiet period used before the first authoritative camera/LOD evaluation. */ + public long initialSettleMs = 150; + /** Maximum first-time mesh constructions per pulse while moving. Zero disables new mesh builds while active. */ + public int activeBuildsPerPulse = 1; + /** Maximum first-time mesh constructions per pulse after settling. */ + public int settledBuildsPerPulse = 2; + + public Config copy() { + Config c = new Config(); + c.targetPixelsPerCell = targetPixelsPerCell; + c.lowThreshold = lowThreshold; + c.highThreshold = highThreshold; + c.finestAllowedLod = finestAllowedLod; + c.geometryBudgetEnabled = geometryBudgetEnabled; + c.triangleBudget = triangleBudget; + c.verboseDiagnostics = verboseDiagnostics; + c.throttleMs = throttleMs; + c.debounceMs = debounceMs; + c.minDepth = minDepth; + c.frustumMargin = frustumMargin; + c.maxNeighborLodDelta = maxNeighborLodDelta; + c.activeTransitionsPerPulse = activeTransitionsPerPulse; + c.settledTransitionsPerPulse = settledTransitionsPerPulse; + c.initialSettleMs = initialSettleMs; + c.activeBuildsPerPulse = activeBuildsPerPulse; + c.settledBuildsPerPulse = settledBuildsPerPulse; + return c; + } + } + + private final PerspectiveCamera camera; + private final SubScene subScene; + private final Node surfaceNode; + private final PauseTransition debounceTimer; + private final PauseTransition initialEvaluationTimer; + private final javafx.beans.value.ChangeListener resizeListener = + (obs, oldValue, newValue) -> requestUpdate(LodManager.UpdateReason.RESIZE); + + private final PriorityQueue pendingTransitions = new PriorityQueue<>(); + private final ArrayList buildDeferredTransitions = new ArrayList<>(); + + private Config config = new Config(); + private List levels = List.of(); + private List tiles = List.of(); + private TileSpec[] tileById = new TileSpec[0]; + private int stateSize; + private int gridColumns; + private int gridRows; + private int[] tileIdByGrid = new int[0]; + + // Reusable per-tile state. -1 means unknown/uninitialized for LOD arrays. + private int[] qualityLodByTile = new int[0]; + private int[] desiredLodByTile = new int[0]; + private int[] appliedLodByTile = new int[0]; + private int[] selectedLodByTile = new int[0]; + private boolean[] visibleByTile = new boolean[0]; + private boolean[] budgetCoarsenedByTile = new boolean[0]; + private double[] depthByTile = new double[0]; + private double[] pixelsPerCellByTile = new double[0]; + private double[] screenDistanceByTile = new double[0]; + private double[] cameraXByTile = new double[0]; + private double[] cameraYByTile = new double[0]; + private double[] cameraDepthByTile = new double[0]; + private double[] cameraRadiusByTile = new double[0]; + + private final AnimationTimer transitionTimer = new AnimationTimer() { + @Override + public void handle(long now) { + processTransitionPulse(); + } + }; + + private double baseWorldWidth = Double.NaN; + private double baseWorldDepth = Double.NaN; + private double heightRadius = 0.0; + private long lastEvalNanos; + private Consumer> compatibilityDecisionListener; + private VisibilityApplier visibilityApplier; + private TransitionApplier transitionApplier; + private GeometryCostProvider geometryCostProvider; + private boolean transitionTimerRunning; + private boolean settledMode; + private boolean initialEvaluationPending; + private int lastVisibleCount; + private int lastDeferredRefinements; + private long lastRawRequestedTriangles; + private long lastBudgetedTargetTriangles; + private int lastBudgetCoarsenedTiles; + + public TiledLodManager(PerspectiveCamera camera, SubScene subScene, Node surfaceNode) { + this.camera = Objects.requireNonNull(camera, "camera"); + this.subScene = Objects.requireNonNull(subScene, "subScene"); + this.surfaceNode = Objects.requireNonNull(surfaceNode, "surfaceNode"); + + debounceTimer = new PauseTransition(Duration.millis(config.debounceMs)); + debounceTimer.setOnFinished(e -> evaluateAndSchedule(false, true)); + initialEvaluationTimer = new PauseTransition(Duration.millis(config.initialSettleMs)); + initialEvaluationTimer.setOnFinished(e -> attemptInitialEvaluation()); + subScene.widthProperty().addListener(resizeListener); + subScene.heightProperty().addListener(resizeListener); + } + + public boolean isVerboseDiagnosticsEnabled() { + return config.verboseDiagnostics; + } + + private void diagnostic(Object message) { + if (config.verboseDiagnostics) { + System.out.println(message); + } + } + + public void setConfig(Config config) { + this.config = Objects.requireNonNull(config, "config").copy(); + validateConfig(this.config); + runOnFx(() -> { + debounceTimer.setDuration(Duration.millis(this.config.debounceMs)); + initialEvaluationTimer.setDuration(Duration.millis(this.config.initialSettleMs)); + diagnostic("Tiled Hypersurface LOD config updated: targetPpc=" + + this.config.targetPixelsPerCell + + ", coarsenBelow=" + this.config.lowThreshold + + ", refineAbove=" + this.config.highThreshold + + ", maxDetail=L" + this.config.finestAllowedLod + + ", geometryBudget=" + this.config.geometryBudgetEnabled + + ", triangleBudget=" + formatTriangleCount(this.config.triangleBudget) + + ", throttleMs=" + this.config.throttleMs + + ", settleMs=" + this.config.debounceMs + + ", initialSettleMs=" + this.config.initialSettleMs + + ", activeTransitions=" + this.config.activeTransitionsPerPulse + + ", settledTransitions=" + this.config.settledTransitionsPerPulse + + ", activeBuilds=" + this.config.activeBuildsPerPulse + + ", settledBuilds=" + this.config.settledBuildsPerPulse); + if (!levels.isEmpty()) { + if (initialEvaluationPending) { + initialEvaluationTimer.stop(); + initialEvaluationTimer.playFromStart(); + } else { + evaluateAndSchedule(true, true); + } + } + }); + } + + public Config getConfigCopy() { + return config.copy(); + } + + /** Compatibility API; normal tiled rendering uses setVisibilityApplier(). */ + public void setOnDecisions(Consumer> onDecisions) { + this.compatibilityDecisionListener = onDecisions; + } + + public void setVisibilityApplier(VisibilityApplier visibilityApplier) { + this.visibilityApplier = visibilityApplier; + } + + public void setTransitionApplier(TransitionApplier transitionApplier) { + this.transitionApplier = transitionApplier; + } + + public void setGeometryCostProvider(GeometryCostProvider geometryCostProvider) { + this.geometryCostProvider = geometryCostProvider; + } + + public long getLastRawRequestedTriangles() { + return lastRawRequestedTriangles; + } + + public long getLastBudgetedTargetTriangles() { + return lastBudgetedTargetTriangles; + } + + public int getLastBudgetCoarsenedTiles() { + return lastBudgetCoarsenedTiles; + } + + public void setSurface(List levels, + List tiles, + double baseWorldWidth, + double baseWorldDepth, + double heightRadius) { + Objects.requireNonNull(levels, "levels"); + Objects.requireNonNull(tiles, "tiles"); + if (levels.isEmpty()) throw new IllegalArgumentException("levels must not be empty"); + if (!(baseWorldWidth > 0.0) || !(baseWorldDepth > 0.0)) { + throw new IllegalArgumentException("base world dimensions must be > 0"); + } + this.levels = List.copyOf(levels); + this.tiles = List.copyOf(tiles); + this.baseWorldWidth = baseWorldWidth; + this.baseWorldDepth = baseWorldDepth; + this.heightRadius = Math.max(0.0, heightRadius); + initializeTileState(this.tiles); + pendingTransitions.clear(); + lastRawRequestedTriangles = 0L; + lastBudgetedTargetTriangles = 0L; + lastBudgetCoarsenedTiles = 0; + stopTransitionTimer(); + lastEvalNanos = 0L; + initialEvaluationPending = true; + runOnFx(() -> { + debounceTimer.stop(); + initialEvaluationTimer.stop(); + initialEvaluationTimer.setDuration(Duration.millis(config.initialSettleMs)); + initialEvaluationTimer.playFromStart(); + diagnostic("Tiled Hypersurface initial LOD deferred: waiting " + + config.initialSettleMs + " ms for stable viewport/camera state"); + }); + } + + public void clearSurface() { + runOnFx(() -> { + levels = List.of(); + tiles = List.of(); + clearTileState(); + pendingTransitions.clear(); + lastRawRequestedTriangles = 0L; + lastBudgetedTargetTriangles = 0L; + lastBudgetCoarsenedTiles = 0; + debounceTimer.stop(); + initialEvaluationTimer.stop(); + initialEvaluationPending = false; + stopTransitionTimer(); + }); + } + + /** Tells the manager that renderer-side LOD views were discarded. */ + public void invalidateRenderedState() { + runOnFx(() -> { + Arrays.fill(appliedLodByTile, -1); + pendingTransitions.clear(); + stopTransitionTimer(); + }); + } + + public void requestUpdate(LodManager.UpdateReason reason) { + runOnFx(() -> { + if (initialEvaluationPending) { + scheduleInitialEvaluation(); + return; + } + + debounceTimer.stop(); + debounceTimer.setDuration(Duration.millis(config.debounceMs)); + debounceTimer.playFromStart(); + + settledMode = false; + int cancelledRefinements = cancelQueuedRefinements(); + if (cancelledRefinements > 0) { + diagnostic("Tiled Hypersurface active camera: paused " + + cancelledRefinements + " queued refinements"); + } + + long now = System.nanoTime(); + long throttleNanos = Math.max(0L, config.throttleMs) * 1_000_000L; + if (throttleNanos == 0L || now - lastEvalNanos >= throttleNanos) { + evaluateAndSchedule(false, false); + } + }); + } + + public void forceUpdate() { + runOnFx(() -> { + debounceTimer.stop(); + if (initialEvaluationPending) { + scheduleInitialEvaluation(); + return; + } + evaluateAndSchedule(true, true); + }); + } + + public int getPendingTransitionCount() { + return pendingTransitions.size(); + } + + public void dispose() { + runOnFx(() -> { + debounceTimer.stop(); + initialEvaluationTimer.stop(); + stopTransitionTimer(); + subScene.widthProperty().removeListener(resizeListener); + subScene.heightProperty().removeListener(resizeListener); + }); + } + + private void initializeTileState(List tileSpecs) { + int maxId = -1; + int maxColumn = -1; + int maxRow = -1; + for (TileSpec tile : tileSpecs) { + maxId = Math.max(maxId, tile.id); + maxColumn = Math.max(maxColumn, tile.column); + maxRow = Math.max(maxRow, tile.row); + } + + stateSize = Math.max(0, maxId + 1); + gridColumns = Math.max(0, maxColumn + 1); + gridRows = Math.max(0, maxRow + 1); + tileById = new TileSpec[stateSize]; + tileIdByGrid = new int[Math.max(0, gridColumns * gridRows)]; + Arrays.fill(tileIdByGrid, -1); + + qualityLodByTile = newIntState(stateSize, -1); + desiredLodByTile = newIntState(stateSize, -1); + appliedLodByTile = newIntState(stateSize, -1); + selectedLodByTile = newIntState(stateSize, -1); + visibleByTile = new boolean[stateSize]; + budgetCoarsenedByTile = new boolean[stateSize]; + depthByTile = new double[stateSize]; + pixelsPerCellByTile = new double[stateSize]; + screenDistanceByTile = new double[stateSize]; + cameraXByTile = new double[stateSize]; + cameraYByTile = new double[stateSize]; + cameraDepthByTile = new double[stateSize]; + cameraRadiusByTile = new double[stateSize]; + + for (TileSpec tile : tileSpecs) { + if (tile.id < 0 || tile.id >= stateSize) continue; + tileById[tile.id] = tile; + if (tile.column >= 0 && tile.column < gridColumns + && tile.row >= 0 && tile.row < gridRows) { + tileIdByGrid[tile.row * gridColumns + tile.column] = tile.id; + } + } + } + + private void clearTileState() { + stateSize = 0; + gridColumns = 0; + gridRows = 0; + tileById = new TileSpec[0]; + tileIdByGrid = new int[0]; + qualityLodByTile = new int[0]; + desiredLodByTile = new int[0]; + appliedLodByTile = new int[0]; + selectedLodByTile = new int[0]; + visibleByTile = new boolean[0]; + budgetCoarsenedByTile = new boolean[0]; + depthByTile = new double[0]; + pixelsPerCellByTile = new double[0]; + screenDistanceByTile = new double[0]; + cameraXByTile = new double[0]; + cameraYByTile = new double[0]; + cameraDepthByTile = new double[0]; + cameraRadiusByTile = new double[0]; + } + + private static int[] newIntState(int size, int initialValue) { + int[] values = new int[size]; + Arrays.fill(values, initialValue); + return values; + } + + private void scheduleInitialEvaluation() { + initialEvaluationTimer.stop(); + initialEvaluationTimer.setDuration(Duration.millis(config.initialSettleMs)); + initialEvaluationTimer.playFromStart(); + } + + private void attemptInitialEvaluation() { + if (!initialEvaluationPending) return; + + double width = subScene.getWidth(); + double height = subScene.getHeight(); + if (!(width > 1.0) || !(height > 1.0) + || !Double.isFinite(width) || !Double.isFinite(height)) { + diagnostic("Tiled Hypersurface initial LOD still deferred: viewport=" + + width + "x" + height); + scheduleInitialEvaluation(); + return; + } + + initialEvaluationPending = false; + diagnostic("Tiled Hypersurface initial LOD released: viewport=" + + String.format("%.1fx%.1f", width, height)); + evaluateAndSchedule(true, true); + } + + private void evaluateAndSchedule(boolean force, boolean settled) { + if (levels.isEmpty() || tiles.isEmpty() || stateSize == 0) return; + if (!Double.isFinite(baseWorldWidth) || !Double.isFinite(baseWorldDepth)) return; + + final double viewportWidth = subScene.getWidth(); + final double viewportHeight = subScene.getHeight(); + if (!(viewportWidth > 1.0) || !(viewportHeight > 1.0)) return; + final double aspect = viewportWidth / viewportHeight; + final double fovRadians = Math.toRadians(camera.getFieldOfView()); + final double tanHalfFov = Math.tan(fovRadians * 0.5); + if (!(tanHalfFov > 0.0)) return; + + Arrays.fill(visibleByTile, false); + Arrays.fill(selectedLodByTile, -1); + Arrays.fill(budgetCoarsenedByTile, false); + + int deferredRefinements = 0; + int visibleCount = 0; + + for (TileSpec tile : tiles) { + int id = tile.id; + if (id < 0 || id >= stateSize) continue; + + computeCameraTile(tile, id); + boolean visible = isVisible(id, aspect, tanHalfFov); + visibleByTile[id] = visible; + int previousQuality = qualityLodByTile[id] >= 0 + ? qualityLodByTile[id] + : appliedLodByTile[id]; + + if (!visible) { + selectedLodByTile[id] = previousQuality >= 0 + ? previousQuality : levels.size() - 1; + depthByTile[id] = cameraDepthByTile[id]; + pixelsPerCellByTile[id] = 0.0; + screenDistanceByTile[id] = screenDistance(id, aspect, tanHalfFov); + continue; + } + + visibleCount++; + double depth = Math.max(config.minDepth, + cameraDepthByTile[id] - cameraRadiusByTile[id] * 0.35); + int selected = chooseLod(previousQuality, depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + selectedLodByTile[id] = selected; + depthByTile[id] = depth; + pixelsPerCellByTile[id] = pixelsPerCell(levels.get(selected), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + screenDistanceByTile[id] = screenDistance(id, aspect, tanHalfFov); + } + + if (config.maxNeighborLodDelta >= 0) { + enforceNeighborDelta(); + refreshPixelsPerCell(viewportWidth, viewportHeight, aspect, tanHalfFov); + } + + // Preserve hysteresis quality before aggregate geometry budgeting. + lastVisibleCount = visibleCount; + for (TileSpec tile : tiles) { + int id = tile.id; + if (isVisibleTile(id)) { + qualityLodByTile[id] = selectedLodByTile[id]; + } + } + + applyGeometryBudget(viewportWidth, viewportHeight, aspect, tanHalfFov); + + if (!settled) { + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int applied = appliedLodByTile[id]; + if (applied >= 0 && selectedLodByTile[id] < applied) { + deferredRefinements++; + selectedLodByTile[id] = applied; + pixelsPerCellByTile[id] = pixelsPerCell(levels.get(applied), depthByTile[id], + viewportWidth, viewportHeight, aspect, tanHalfFov); + } + } + } + + int desiredChanges = 0; + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int previousDesired = desiredLodByTile[id] >= 0 + ? desiredLodByTile[id] : appliedLodByTile[id]; + if (selectedLodByTile[id] != previousDesired) desiredChanges++; + desiredLodByTile[id] = selectedLodByTile[id]; + } + + lastDeferredRefinements = deferredRefinements; + settledMode = settled; + + if (visibilityApplier != null) { + visibilityApplier.apply(visibleByTile, stateSize); + } + publishCompatibilityDecisions(); + rebuildTransitionQueue(); + lastEvalNanos = System.nanoTime(); + + if (config.verboseDiagnostics + && (desiredChanges > 0 || !pendingTransitions.isEmpty() || force || deferredRefinements > 0)) { + int[] desiredCounts = countDesiredLods(); + int queuedCoarsen = 0; + int queuedRefine = 0; + int queuedInitial = 0; + for (LodTransition transition : pendingTransitions) { + if (transition.isInitial()) queuedInitial++; + else if (transition.isCoarsening()) queuedCoarsen++; + else if (transition.isRefining()) queuedRefine++; + } + StringBuilder sb = new StringBuilder("Tiled Hypersurface target: visible=") + .append(visibleCount).append('/').append(tiles.size()) + .append(", desiredChanges=").append(desiredChanges) + .append(", queued=").append(pendingTransitions.size()) + .append(", coarsenQueued=").append(queuedCoarsen) + .append(", refineQueued=").append(queuedRefine) + .append(", initialQueued=").append(queuedInitial) + .append(", refineDeferred=").append(deferredRefinements) + .append(", mode=").append(settled ? "settled" : "active") + .append(", transitionBudget=").append(currentTransitionBudget()) + .append(", buildBudget=").append(currentBuildBudget()) + .append(", rawTriangles=").append(formatTriangleCount(lastRawRequestedTriangles)) + .append(", budgetTriangles=").append(formatTriangleCount(lastBudgetedTargetTriangles)) + .append(", budgetCoarsened=").append(lastBudgetCoarsenedTiles) + .append(", geometryBudget=") + .append(config.geometryBudgetEnabled ? formatTriangleCount(config.triangleBudget) : "OFF") + .append(", "); + appendLodCounts(sb, desiredCounts); + diagnostic(sb); + if (settled && queuedRefine > 0) { + diagnostic("Tiled Hypersurface settled: refinementReleased=" + + queuedRefine + ", queuedTotal=" + pendingTransitions.size()); + } + } + + if (!pendingTransitions.isEmpty()) startTransitionTimer(); + else stopTransitionTimer(); + } + + private void applyGeometryBudget(double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + long rawTriangles = totalSelectedTriangles(); + lastRawRequestedTriangles = rawTriangles; + lastBudgetedTargetTriangles = rawTriangles; + lastBudgetCoarsenedTiles = 0; + + if (!config.geometryBudgetEnabled + || config.triangleBudget <= 0L + || geometryCostProvider == null + || rawTriangles <= config.triangleBudget) { + return; + } + + long triangles = rawTriangles; + int guard = Math.max(1, lastVisibleCount * Math.max(1, levels.size()) * 2); + + while (triangles > config.triangleBudget && guard-- > 0) { + int bestTileId = -1; + int bestToLod = -1; + long bestSavings = 0L; + + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int current = selectedLodByTile[id]; + if (current < 0 || current >= levels.size() - 1) continue; + + int proposed = current + 1; + if (!budgetNeighborCompatible(tile, proposed)) continue; + + long savings = triangleCost(id, current) - triangleCost(id, proposed); + if (savings <= 0L) continue; + + if (bestTileId < 0 || lowerBudgetPriority(id, savings, bestTileId, bestSavings)) { + bestTileId = id; + bestToLod = proposed; + bestSavings = savings; + } + } + + if (bestTileId < 0) break; + selectedLodByTile[bestTileId] = bestToLod; + if (!budgetCoarsenedByTile[bestTileId]) { + budgetCoarsenedByTile[bestTileId] = true; + lastBudgetCoarsenedTiles++; + } + triangles -= bestSavings; + } + + refreshPixelsPerCell(viewportWidth, viewportHeight, aspect, tanHalfFov); + long exactBudgetedTriangles = totalSelectedTriangles(); + lastBudgetedTargetTriangles = exactBudgetedTriangles; + if (exactBudgetedTriangles > config.triangleBudget) { + diagnostic("Tiled Hypersurface geometry budget warning: requested=" + + formatTriangleCount(rawTriangles) + + ", budget=" + formatTriangleCount(config.triangleBudget) + + ", achievable=" + formatTriangleCount(exactBudgetedTriangles) + + ", neighborDelta=" + config.maxNeighborLodDelta); + } + } + + private boolean lowerBudgetPriority(int candidateId, long candidateSavings, + int currentBestId, long currentBestSavings) { + int screenCmp = Double.compare(screenDistanceByTile[candidateId], + screenDistanceByTile[currentBestId]); + if (screenCmp != 0) return screenCmp > 0; + int depthCmp = Double.compare(depthByTile[candidateId], depthByTile[currentBestId]); + if (depthCmp != 0) return depthCmp > 0; + return candidateSavings > currentBestSavings; + } + + private long totalSelectedTriangles() { + if (geometryCostProvider == null) return 0L; + long total = 0L; + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = selectedLodByTile[id]; + if (lod >= 0) total += triangleCost(id, lod); + } + return total; + } + + private long triangleCost(int tileId, int lod) { + if (geometryCostProvider == null) return 0L; + return Math.max(0L, geometryCostProvider.trianglesFor(tileId, lod)); + } + + private boolean budgetNeighborCompatible(TileSpec tile, int proposedLod) { + if (config.maxNeighborLodDelta < 0) return true; + return budgetNeighborCompatible(proposedLod, neighborLod(tile.column - 1, tile.row)) + && budgetNeighborCompatible(proposedLod, neighborLod(tile.column + 1, tile.row)) + && budgetNeighborCompatible(proposedLod, neighborLod(tile.column, tile.row - 1)) + && budgetNeighborCompatible(proposedLod, neighborLod(tile.column, tile.row + 1)); + } + + private boolean budgetNeighborCompatible(int proposedLod, int neighborLod) { + return neighborLod < 0 + || proposedLod <= neighborLod + config.maxNeighborLodDelta; + } + + private int cancelQueuedRefinements() { + int before = pendingTransitions.size(); + pendingTransitions.removeIf(LodTransition::isRefining); + int removed = before - pendingTransitions.size(); + if (pendingTransitions.isEmpty()) stopTransitionTimer(); + return removed; + } + + private void rebuildTransitionQueue() { + pendingTransitions.clear(); + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int from = appliedLodByTile[id]; + int to = desiredLodByTile[id]; + if (to < 0 || from == to) continue; + + int priorityClass; + if (from >= 0 && to > from) priorityClass = 0; + else if (from < 0) priorityClass = 1; + else priorityClass = 2; + + pendingTransitions.add(new LodTransition( + id, from, to, priorityClass, + screenDistanceByTile[id], depthByTile[id], pixelsPerCellByTile[id])); + } + } + + private void processTransitionPulse() { + if (pendingTransitions.isEmpty() || transitionApplier == null) { + stopTransitionTimer(); + return; + } + + int transitionBudget = currentTransitionBudget(); + int buildBudget = currentBuildBudget(); + int applied = 0; + int built = 0; + int cacheHits = 0; + int coarsened = 0; + int refined = 0; + int initial = 0; + + int transitionsAvailableAtStart = pendingTransitions.size(); + buildDeferredTransitions.clear(); + int examined = 0; + + while (applied < transitionBudget + && !pendingTransitions.isEmpty() + && examined < transitionsAvailableAtStart) { + + LodTransition transition = pendingTransitions.poll(); + examined++; + int id = transition.tileId(); + if (id < 0 || id >= stateSize) continue; + int desired = desiredLodByTile[id]; + if (desired < 0 || desired != transition.toLod()) continue; + + int actualFrom = appliedLodByTile[id]; + if (actualFrom == transition.toLod()) continue; + + LodTransition actualTransition = actualFrom == transition.fromLod() + ? transition + : new LodTransition(id, actualFrom, transition.toLod(), + transition.priorityClass(), transition.screenDistance(), + transition.depth(), transition.pixelsPerCell()); + + boolean allowBuild = built < buildBudget; + TransitionResult result = transitionApplier.apply(actualTransition, allowBuild); + if (!result.applied()) { + if (!allowBuild) buildDeferredTransitions.add(actualTransition); + continue; + } + + appliedLodByTile[id] = actualTransition.toLod(); + applied++; + if (result.built()) built++; + if (result.cacheHit()) cacheHits++; + if (actualTransition.isInitial()) initial++; + else if (actualTransition.isCoarsening()) coarsened++; + else if (actualTransition.isRefining()) refined++; + + if (buildBudget > 0 && built >= buildBudget) break; + } + + pendingTransitions.addAll(buildDeferredTransitions); + + if (config.verboseDiagnostics && applied > 0) { + int[] actualCounts = countAppliedVisibleLods(); + StringBuilder sb = new StringBuilder("Tiled Hypersurface transition pulse: visible=") + .append(lastVisibleCount).append('/').append(tiles.size()) + .append(", applied=").append(applied) + .append(", built=").append(built) + .append(", cacheHits=").append(cacheHits) + .append(", coarsened=").append(coarsened) + .append(", refined=").append(refined) + .append(", initial=").append(initial) + .append(", remaining=").append(pendingTransitions.size()) + .append(", refineDeferred=").append(lastDeferredRefinements) + .append(", mode=").append(settledMode ? "settled" : "active") + .append(", actual="); + appendLodCounts(sb, actualCounts); + diagnostic(sb); + } + + if (pendingTransitions.isEmpty() + || (!settledMode && buildBudget == 0 && applied == 0)) { + stopTransitionTimer(); + } + } + + private int currentTransitionBudget() { + return Math.max(1, settledMode + ? config.settledTransitionsPerPulse + : config.activeTransitionsPerPulse); + } + + private int currentBuildBudget() { + return Math.max(0, settledMode + ? config.settledBuildsPerPulse + : config.activeBuildsPerPulse); + } + + private int chooseLod(int current, double depth, + double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + int finestAllowed = Math.max(0, Math.min(config.finestAllowedLod, levels.size() - 1)); + if (current < finestAllowed || current >= levels.size()) { + int best = levels.size() - 1; + for (int i = finestAllowed; i < levels.size(); i++) { + if (pixelsPerCell(levels.get(i), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov) + >= config.targetPixelsPerCell) { + best = i; + break; + } + } + return best; + } + + int selected = current; + while (selected < levels.size() - 1) { + double ppc = pixelsPerCell(levels.get(selected), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + if (ppc >= config.lowThreshold) break; + selected++; + } + while (selected > finestAllowed) { + double ppc = pixelsPerCell(levels.get(selected), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + if (ppc <= config.highThreshold) break; + selected--; + } + return Math.max(finestAllowed, selected); + } + + private double pixelsPerCell(LodManager.LodLevel level, double depth, + double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + double pixelsPerWorldUnit; + if (camera.isVerticalFieldOfView()) { + double worldHeight = 2.0 * depth * tanHalfFov; + pixelsPerWorldUnit = viewportHeight / worldHeight; + } else { + double worldWidth = 2.0 * depth * tanHalfFov; + pixelsPerWorldUnit = viewportWidth / worldWidth; + } + double cellX = baseWorldWidth / level.width; + double cellZ = baseWorldDepth / level.height; + return Math.min(cellX, cellZ) * pixelsPerWorldUnit; + } + + private void computeCameraTile(TileSpec tile, int id) { + Point3D scenePoint = surfaceNode.localToScene(tile.centerX(), 0.0, tile.centerZ()); + Point3D cameraPoint = camera.sceneToLocal(scenePoint); + cameraXByTile[id] = cameraPoint.getX(); + cameraYByTile[id] = cameraPoint.getY(); + cameraDepthByTile[id] = cameraPoint.getZ(); + cameraRadiusByTile[id] = Math.sqrt(tile.halfWidth() * tile.halfWidth() + + tile.halfDepth() * tile.halfDepth() + + heightRadius * heightRadius) * Math.max(1.0, config.frustumMargin); + } + + private boolean isVisible(int id, double aspect, double tanHalfFov) { + double z = cameraDepthByTile[id]; + double radius = cameraRadiusByTile[id]; + if (z + radius < camera.getNearClip()) return false; + if (z - radius > camera.getFarClip()) return false; + if (z + radius <= 0.0) return false; + + double depthForExtent = Math.max(config.minDepth, z + radius); + double halfWidth; + double halfHeight; + if (camera.isVerticalFieldOfView()) { + halfHeight = depthForExtent * tanHalfFov; + halfWidth = halfHeight * aspect; + } else { + halfWidth = depthForExtent * tanHalfFov; + halfHeight = halfWidth / Math.max(1.0e-9, aspect); + } + return Math.abs(cameraXByTile[id]) <= halfWidth + radius + && Math.abs(cameraYByTile[id]) <= halfHeight + radius; + } + + private double screenDistance(int id, double aspect, double tanHalfFov) { + double depth = Math.max(config.minDepth, cameraDepthByTile[id]); + double halfWidth; + double halfHeight; + if (camera.isVerticalFieldOfView()) { + halfHeight = depth * tanHalfFov; + halfWidth = halfHeight * aspect; + } else { + halfWidth = depth * tanHalfFov; + halfHeight = halfWidth / Math.max(1.0e-9, aspect); + } + double nx = cameraXByTile[id] / Math.max(config.minDepth, halfWidth); + double ny = cameraYByTile[id] / Math.max(config.minDepth, halfHeight); + return Math.hypot(nx, ny); + } + + private void enforceNeighborDelta() { + boolean changed; + int guard = Math.max(1, levels.size() * 2); + do { + changed = false; + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = selectedLodByTile[id]; + int refined = lod; + refined = refineAgainstNeighbor(refined, neighborLod(tile.column - 1, tile.row)); + refined = refineAgainstNeighbor(refined, neighborLod(tile.column + 1, tile.row)); + refined = refineAgainstNeighbor(refined, neighborLod(tile.column, tile.row - 1)); + refined = refineAgainstNeighbor(refined, neighborLod(tile.column, tile.row + 1)); + if (refined != lod) { + selectedLodByTile[id] = refined; + changed = true; + } + } + } while (changed && --guard > 0); + } + + private int refineAgainstNeighbor(int lod, int neighborLod) { + if (neighborLod < 0) return lod; + int maxAllowed = neighborLod + config.maxNeighborLodDelta; + return lod > maxAllowed ? maxAllowed : lod; + } + + private int neighborLod(int column, int row) { + int neighborId = tileIdAt(column, row); + return isVisibleTile(neighborId) ? selectedLodByTile[neighborId] : -1; + } + + private int tileIdAt(int column, int row) { + if (column < 0 || column >= gridColumns || row < 0 || row >= gridRows) return -1; + return tileIdByGrid[row * gridColumns + column]; + } + + private boolean isVisibleTile(int id) { + return id >= 0 && id < stateSize && visibleByTile[id]; + } + + private void refreshPixelsPerCell(double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = selectedLodByTile[id]; + if (lod >= 0 && lod < levels.size()) { + pixelsPerCellByTile[id] = pixelsPerCell(levels.get(lod), depthByTile[id], + viewportWidth, viewportHeight, aspect, tanHalfFov); + } + } + } + + private int[] countAppliedVisibleLods() { + int[] counts = new int[levels.size()]; + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = appliedLodByTile[id]; + if (lod >= 0 && lod < counts.length) counts[lod]++; + } + return counts; + } + + private int[] countDesiredLods() { + int[] counts = new int[levels.size()]; + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = selectedLodByTile[id]; + if (lod >= 0 && lod < counts.length) counts[lod]++; + } + return counts; + } + + private void publishCompatibilityDecisions() { + if (compatibilityDecisionListener == null) return; + ArrayList decisions = new ArrayList<>(tiles.size()); + for (TileSpec tile : tiles) { + int id = tile.id; + boolean visible = isVisibleTile(id); + int lod = id >= 0 && id < stateSize && selectedLodByTile[id] >= 0 + ? selectedLodByTile[id] : levels.size() - 1; + decisions.add(new TileDecision(id, visible, lod, + id >= 0 && id < stateSize ? depthByTile[id] : 0.0, + id >= 0 && id < stateSize ? pixelsPerCellByTile[id] : 0.0, + id >= 0 && id < stateSize ? screenDistanceByTile[id] : 0.0)); + } + compatibilityDecisionListener.accept(List.copyOf(decisions)); + } + + private static void appendLodCounts(StringBuilder sb, int[] counts) { + for (int i = 0; i < counts.length; i++) { + if (i > 0) sb.append(", "); + sb.append('L').append(i).append('=').append(counts[i]); + } + } + + private void startTransitionTimer() { + if (transitionTimerRunning || transitionApplier == null) return; + transitionTimerRunning = true; + transitionTimer.start(); + } + + private void stopTransitionTimer() { + if (!transitionTimerRunning) return; + transitionTimer.stop(); + transitionTimerRunning = false; + } + + private static void validateConfig(Config config) { + if (!(config.lowThreshold > 0.0) + || !(config.targetPixelsPerCell > 0.0) + || !(config.highThreshold > 0.0) + || config.lowThreshold >= config.highThreshold) { + throw new IllegalArgumentException( + "LOD thresholds must be > 0 and lowThreshold < highThreshold"); + } + if (config.finestAllowedLod < 0) { + throw new IllegalArgumentException("finestAllowedLod must be >= 0"); + } + if (config.triangleBudget < 1L) { + throw new IllegalArgumentException("triangleBudget must be >= 1"); + } + if (config.throttleMs < 0L || config.debounceMs < 0L || config.initialSettleMs < 0L) { + throw new IllegalArgumentException("LOD timing values must be >= 0"); + } + if (config.activeTransitionsPerPulse < 1 + || config.settledTransitionsPerPulse < 1 + || config.activeBuildsPerPulse < 0 + || config.settledBuildsPerPulse < 1) { + throw new IllegalArgumentException( + "Transition budgets and settled build budget must be >= 1; active build budget must be >= 0"); + } + } + + private static void runOnFx(Runnable runnable) { + if (Platform.isFxApplicationThread()) runnable.run(); + else Platform.runLater(runnable); + } + + private static String formatTriangleCount(long triangles) { + if (triangles >= 1_000_000L) { + return String.format("%.2fM", triangles / 1_000_000.0); + } + if (triangles >= 1_000L) { + return String.format("%.1fK", triangles / 1_000.0); + } + return Long.toString(triangles); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java new file mode 100644 index 00000000..c0eca711 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -0,0 +1,527 @@ +package edu.jhuapl.trinity.javafx.javafx3d; + +import javafx.scene.Group; +import javafx.scene.PerspectiveCamera; +import javafx.scene.SubScene; +import javafx.scene.image.Image; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.DrawMode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Tiled renderer for primitive HeightField LOD pyramids. + * + *

Tile scene-graph state is persistent. Each tile/LOD pair receives its own + * {@link HyperSurfacePlotMesh} the first time it is needed, and that MeshView + * remains permanently associated with its TriangleMesh. Subsequent LOD changes + * only toggle which cached MeshView is visible.

+ */ +public final class TiledSurfaceRenderer extends Group { + + public record LodStatistics( + int visibleTiles, + int totalTiles, + int lod0Tiles, + int lod1Tiles, + int lod2Tiles, + int lod3Tiles, + int lod4Tiles, + long visibleTriangles, + long rawRequestedTriangles, + long budgetedTargetTriangles, + int budgetCoarsenedTiles, + int pendingTransitions + ) { } + + public enum ColorMode { + IMAGE, + HEIGHT + } + + /** Immutable precomputed mapping from one logical tile to one pyramid LOD. */ + private record TileLodInfo( + int startX, int startZ, int cellsX, int cellsZ, + double scaleX, double scaleZ, + double translateX, double translateZ, + long triangleCount + ) { } + + private static final int DEFAULT_TILE_CELLS_L0 = 256; + + private final PerspectiveCamera camera; + private final SubScene subScene; + private final TiledLodManager lodManager; + private TileRenderState[] tilesById = new TileRenderState[0]; + private TileLodInfo[][] tileLodInfoByTile = new TileLodInfo[0][]; + + private List levels = List.of(); + private List tileSpecs = List.of(); + private double baseWorldWidth = 1.0; + private double baseWorldDepth = 1.0; + private double yScale = 1.0; + private int tileCellsL0 = DEFAULT_TILE_CELLS_L0; + + private DrawMode drawMode = DrawMode.FILL; + private CullFace cullFace = CullFace.BACK; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + private SurfaceHeightOrientation heightOrientation = SurfaceHeightOrientation.HIGH_VALUES_UP; + private ColorMode colorMode = ColorMode.HEIGHT; + private Image image; + private int paletteColors = 1530; + private double colorMin = 0.0; + private double colorMax = 1.0; + private Color specularColor = Color.WHITE; + + private int visibleTileCount; + private int lastRebuildCount; + private int lastCacheHitCount; + private int lastLodChangeCount; + + public TiledSurfaceRenderer(PerspectiveCamera camera, SubScene subScene) { + this.camera = Objects.requireNonNull(camera, "camera"); + this.subScene = Objects.requireNonNull(subScene, "subScene"); + this.lodManager = new TiledLodManager(camera, subScene, this); + this.lodManager.setVisibilityApplier(this::applyVisibilityDecisions); + this.lodManager.setTransitionApplier(this::applyTransition); + this.lodManager.setGeometryCostProvider(this::triangleCountForTileId); + setPickOnBounds(false); + // Surface ray-picking is expensive for dense TriangleMeshes. Hover is off by + // default, so keep the entire tiled subtree mouse-transparent until explicitly enabled. + setMouseTransparent(true); + } + + public void setHoverPickingEnabled(boolean enabled) { + setMouseTransparent(!enabled); + } + + public boolean isHoverPickingEnabled() { + return !isMouseTransparent(); + } + + public boolean isVerboseDiagnosticsEnabled() { + return lodManager.isVerboseDiagnosticsEnabled(); + } + + private void diagnostic(Object message) { + if (lodManager.isVerboseDiagnosticsEnabled()) { + System.out.println(message); + } + } + + public void setTileCellsL0(int tileCellsL0) { + if (tileCellsL0 < 16) { + throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + } + if (this.tileCellsL0 == tileCellsL0) return; + this.tileCellsL0 = tileCellsL0; + diagnostic("Tiled Hypersurface tile size updated: tileCellsL0=" + tileCellsL0); + if (!levels.isEmpty()) { + rebuildTileLayout(); + forceUpdate(); + } + } + + public int getTileCellsL0() { + return tileCellsL0; + } + + public void setLodConfig(TiledLodManager.Config config) { + lodManager.setConfig(config); + } + + public TiledLodManager.Config getLodConfigCopy() { + return lodManager.getConfigCopy(); + } + + public void setPyramid(List levels, + double baseWorldWidth, + double baseWorldDepth, + double yScale) { + Objects.requireNonNull(levels, "levels"); + if (levels.isEmpty()) { + throw new IllegalArgumentException("levels must not be empty"); + } + if (!(baseWorldWidth > 0.0) || !(baseWorldDepth > 0.0)) { + throw new IllegalArgumentException("base world dimensions must be > 0"); + } + this.levels = List.copyOf(levels); + this.baseWorldWidth = baseWorldWidth; + this.baseWorldDepth = baseWorldDepth; + this.yScale = yScale; + setTranslateX(-baseWorldWidth * 0.5); + setTranslateZ(-baseWorldDepth * 0.5); + rebuildTileLayout(); + } + + public void clearSurface() { + lodManager.clearSurface(); + tilesById = new TileRenderState[0]; + tileLodInfoByTile = new TileLodInfo[0][]; + tileSpecs = List.of(); + levels = List.of(); + getChildren().clear(); + image = null; + visibleTileCount = 0; + } + + public void requestUpdate(LodManager.UpdateReason reason) { + if (!levels.isEmpty()) lodManager.requestUpdate(reason); + } + + public void forceUpdate() { + if (!levels.isEmpty()) lodManager.forceUpdate(); + } + + public void dispose() { + lodManager.dispose(); + tilesById = new TileRenderState[0]; + tileLodInfoByTile = new TileLodInfo[0][]; + tileSpecs = List.of(); + levels = List.of(); + getChildren().clear(); + image = null; + } + + public void setYScale(double yScale) { + if (Double.compare(this.yScale, yScale) == 0) return; + this.yScale = yScale; + + // Geometry Y coordinates changed, so all cached per-LOD MeshViews are invalid. + getChildren().clear(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); + } + lodManager.invalidateRenderedState(); + forceUpdate(); + } + + public void setDrawMode(DrawMode drawMode) { + this.drawMode = Objects.requireNonNull(drawMode, "drawMode"); + forEachBuiltView(view -> view.setDrawMode(drawMode)); + } + + public void setCullFace(CullFace cullFace) { + this.cullFace = Objects.requireNonNull(cullFace, "cullFace"); + forEachBuiltView(view -> view.setCullFace(cullFace)); + } + + public SurfaceRowOrientation getRowOrientation() { + return rowOrientation; + } + + public void setRowOrientation(SurfaceRowOrientation rowOrientation) { + SurfaceRowOrientation next = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + if (this.rowOrientation == next) return; + this.rowOrientation = next; + + // Row orientation changes which source row feeds every mesh Z row. Cached + // per-LOD meshes therefore cannot be reused across an orientation change. + getChildren().clear(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); + } + lodManager.invalidateRenderedState(); + } + + public SurfaceHeightOrientation getHeightOrientation() { + return heightOrientation; + } + + public void setHeightOrientation(SurfaceHeightOrientation heightOrientation) { + SurfaceHeightOrientation next = heightOrientation != null + ? heightOrientation + : SurfaceHeightOrientation.HIGH_VALUES_UP; + if (this.heightOrientation == next) return; + this.heightOrientation = next; + + // Height orientation changes every mesh Y coordinate. Cached per-LOD meshes + // therefore cannot be reused across an orientation change. + getChildren().clear(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); + } + lodManager.invalidateRenderedState(); + } + + public void setSpecularColor(Color specularColor) { + this.specularColor = Objects.requireNonNull(specularColor, "specularColor"); + forEachBuiltView(this::applySpecular); + } + + public void setColorByImage(Image image) { + this.image = image; + this.colorMode = ColorMode.IMAGE; + forEachBuiltView(this::applyColoration); + } + + public void setColorByHeight(int colors, double min, double max) { + if (colors < 2) throw new IllegalArgumentException("colors must be >= 2"); + if (!(max > min)) throw new IllegalArgumentException("max must be > min"); + this.paletteColors = colors; + this.colorMin = min; + this.colorMax = max; + this.colorMode = ColorMode.HEIGHT; + forEachBuiltView(this::applyColoration); + } + + public int getVisibleTileCount() { + return visibleTileCount; + } + + public int getTotalTileCount() { + return tileSpecs.size(); + } + + public int getLastRebuildCount() { + return lastRebuildCount; + } + + public int getLastCacheHitCount() { + return lastCacheHitCount; + } + + public int getLastLodChangeCount() { + return lastLodChangeCount; + } + + public int getPendingTransitionCount() { + return lodManager.getPendingTransitionCount(); + } + + public LodStatistics getLodStatistics() { + int visible = 0; + int lod0 = 0; + int lod1 = 0; + int lod2 = 0; + int lod3 = 0; + int lod4 = 0; + long triangles = 0L; + + for (TileRenderState tile : tilesById) { + if (tile == null || !tile.isTileVisible()) continue; + visible++; + int lod = tile.getActiveLod(); + if (lod == 0) lod0++; + else if (lod == 1) lod1++; + else if (lod == 2) lod2++; + else if (lod == 3) lod3++; + else if (lod >= 4) lod4++; + if (lod >= 0 && lod < levels.size()) { + triangles += triangleCountForTileId(tile.getTileId(), lod); + } + } + + return new LodStatistics( + visible, tileSpecs.size(), lod0, lod1, lod2, lod3, lod4, triangles, + lodManager.getLastRawRequestedTriangles(), + lodManager.getLastBudgetedTargetTriangles(), + lodManager.getLastBudgetCoarsenedTiles(), + lodManager.getPendingTransitionCount()); + } + + private long triangleCountForTileId(int tileId, int lod) { + if (tileId < 0 || tileId >= tileLodInfoByTile.length) return 0L; + TileLodInfo[] infos = tileLodInfoByTile[tileId]; + if (infos == null || lod < 0 || lod >= infos.length) return 0L; + TileLodInfo info = infos[lod]; + return info != null ? info.triangleCount() : 0L; + } + + private void rebuildTileLayout() { + getChildren().clear(); + + HeightField l0 = levels.get(0); + int l0Width = l0.width(); + int l0Height = l0.height(); + int columns = Math.max(1, (int) Math.ceil(l0Width / (double) tileCellsL0)); + int rows = Math.max(1, (int) Math.ceil(l0Height / (double) tileCellsL0)); + int tileCount = Math.multiplyExact(columns, rows); + + tilesById = new TileRenderState[tileCount]; + tileLodInfoByTile = new TileLodInfo[tileCount][levels.size()]; + ArrayList specs = new ArrayList<>(tileCount); + + int id = 0; + for (int row = 0; row < rows; row++) { + int startZ0 = row * tileCellsL0; + int endZ0 = Math.min(l0Height, startZ0 + tileCellsL0); + double minZ = startZ0 * baseWorldDepth / l0Height; + double maxZ = endZ0 * baseWorldDepth / l0Height; + + for (int column = 0; column < columns; column++) { + int startX0 = column * tileCellsL0; + int endX0 = Math.min(l0Width, startX0 + tileCellsL0); + double minX = startX0 * baseWorldWidth / l0Width; + double maxX = endX0 * baseWorldWidth / l0Width; + + TiledLodManager.TileSpec spec = new TiledLodManager.TileSpec( + id, column, row, minX, maxX, minZ, maxZ); + specs.add(spec); + tilesById[id] = new TileRenderState( + spec, startX0, endX0, startZ0, endZ0, levels.size()); + + for (int lod = 0; lod < levels.size(); lod++) { + HeightField field = levels.get(lod); + int startX = mapBoundary(startX0, l0Width, field.width()); + int endX = mapBoundary(endX0, l0Width, field.width()); + int startZ = mapBoundary(startZ0, l0Height, field.height()); + int endZ = mapBoundary(endZ0, l0Height, field.height()); + endX = Math.max(startX + 1, Math.min(field.width(), endX)); + endZ = Math.max(startZ + 1, Math.min(field.height(), endZ)); + startX = Math.max(0, Math.min(startX, endX - 1)); + startZ = Math.max(0, Math.min(startZ, endZ - 1)); + + int cellsX = endX - startX; + int cellsZ = endZ - startZ; + double scaleX = baseWorldWidth / field.width(); + double scaleZ = baseWorldDepth / field.height(); + tileLodInfoByTile[id][lod] = new TileLodInfo( + startX, startZ, cellsX, cellsZ, scaleX, scaleZ, + startX * scaleX, startZ * scaleZ, + (long) cellsX * cellsZ * 2L); + } + id++; + } + } + tileSpecs = List.copyOf(specs); + + float[] minMax = l0.minMax(); + double maxAbsHeight = Math.max( + Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + lodManager.setSurface( + LodManager.fromHeightFields(levels), + tileSpecs, + baseWorldWidth, + baseWorldDepth, + maxAbsHeight + ); + + diagnostic("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + + " (" + tileSpecs.size() + "), tileCellsL0=" + tileCellsL0 + + ", world=" + baseWorldWidth + "x" + baseWorldDepth + + ", persistentLodViews=true, precomputedTileLodInfo=true"); + } + + /** + * Visibility/frustum changes are applied immediately. LOD changes are not; + * they are queued and budgeted by TiledLodManager. + */ + private void applyVisibilityDecisions(boolean[] visibleByTile, int tileStateSize) { + int visible = 0; + int limit = Math.min(Math.min(tileStateSize, visibleByTile.length), tilesById.length); + for (int tileId = 0; tileId < limit; tileId++) { + TileRenderState tile = tilesById[tileId]; + if (tile == null) continue; + boolean tileVisible = visibleByTile[tileId]; + tile.setTileVisible(tileVisible); + if (tileVisible) visible++; + + HyperSurfacePlotMesh activeView = tile.getLodView(tile.getActiveLod()); + if (activeView != null) { + activeView.setVisible(tileVisible && isVisible()); + } + } + visibleTileCount = visible; + } + + /** + * Applies one budgeted LOD transition. Existing tile/LOD MeshViews are only + * shown/hidden; no MeshView.setMesh(...) operation occurs after construction. + */ + private TiledLodManager.TransitionResult applyTransition( + LodTransition transition, boolean allowBuild) { + int tileId = transition.tileId(); + TileRenderState tile = tileId >= 0 && tileId < tilesById.length + ? tilesById[tileId] : null; + if (tile == null) return TiledLodManager.TransitionResult.notApplied(); + + int lod = Math.max(0, Math.min(transition.toLod(), levels.size() - 1)); + HyperSurfacePlotMesh targetView = tile.getLodView(lod); + boolean built = false; + + if (targetView == null) { + if (!allowBuild) { + return TiledLodManager.TransitionResult.notApplied(); + } + targetView = buildTileLodView(tile, lod); + tile.setLodView(lod, targetView); + built = true; + } + + HyperSurfacePlotMesh previousView = tile.getLodView(tile.getActiveLod()); + if (previousView != null && previousView != targetView) { + previousView.setVisible(false); + } + + tile.setActiveLod(lod); + targetView.setVisible(tile.isTileVisible() && isVisible()); + + lastLodChangeCount = 1; + lastRebuildCount = built ? 1 : 0; + lastCacheHitCount = built ? 0 : 1; + return built + ? TiledLodManager.TransitionResult.builtResult() + : TiledLodManager.TransitionResult.cacheHitResult(); + } + + private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { + HeightField field = levels.get(lod); + int tileId = tile.getTileId(); + TileLodInfo info = tileLodInfoByTile[tileId][lod]; + + HyperSurfacePlotMesh view = new HyperSurfacePlotMesh( + 1, 1, 1, 1, yScale, 1.0, p -> 0.0); + view.setDrawMode(drawMode); + view.setCullFace(cullFace); + view.setRowOrientation(rowOrientation); + view.setHeightOrientation(heightOrientation); + view.updateMeshHeightField(field, info.startX(), info.startZ(), + info.cellsX(), info.cellsZ(), info.scaleX(), yScale, info.scaleZ()); + view.setTranslateX(info.translateX()); + view.setTranslateZ(info.translateZ()); + view.setVisible(false); + applyColoration(view); + applySpecular(view); + getChildren().add(view); + return view; + } + + private void applyColoration(HyperSurfacePlotMesh view) { + if (view == null || !view.isDirectHeightFieldMesh()) return; + if (colorMode == ColorMode.IMAGE) { + if (image != null) view.setDirectTextureModeImage(image); + } else { + view.setDirectTextureModeByHeight(paletteColors, colorMin, colorMax); + } + } + + private void applySpecular(HyperSurfacePlotMesh view) { + if (view == null) return; + if (view.getMaterial() instanceof PhongMaterial material) { + material.setSpecularColor(specularColor); + } + } + + private void forEachBuiltView(java.util.function.Consumer consumer) { + for (TileRenderState tile : tilesById) { + if (tile == null) continue; + for (int lod = 0; lod < tile.getLodCount(); lod++) { + HyperSurfacePlotMesh view = tile.getLodView(lod); + if (view != null) consumer.accept(view); + } + } + } + + private static int mapBoundary(int sourceBoundary, int sourceSize, int targetSize) { + if (sourceBoundary <= 0) return 0; + if (sourceBoundary >= sourceSize) return targetSize; + return (int) Math.round(sourceBoundary * targetSize / (double) sourceSize); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java index cb4d8ef2..f8fc6ea3 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java +++ b/src/main/java/edu/jhuapl/trinity/utils/statistics/DivergenceComputer.java @@ -43,7 +43,6 @@ * This class does not modify any global UI state and is thread-safe per call. * * @author Sean Phillips (Trinity) - * @author (helper) ChatGPT */ public final class DivergenceComputer {