From 2927feb3e948d7127382afb5c521e83c4870f07c Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 7 Apr 2026 12:56:37 -0400 Subject: [PATCH 01/13] First cut at Level of Detail support for Hypersurface. Needs a shit ton of testing still. --- .../messages/xai/TextEmbeddingCollection.java | 2 +- .../trinity/javafx/javafx3d/HeightField.java | 126 +++++ .../javafx/javafx3d/Hypersurface3DPane.java | 219 +++++++- .../trinity/javafx/javafx3d/LodManager.java | 326 +++++++++++ .../trinity/javafx/javafx3d/SurfaceUtils.java | 517 +++++++++++++++++- .../utils/statistics/DivergenceComputer.java | 1 - 6 files changed, 1161 insertions(+), 30 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HeightField.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java 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/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/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java index 2c53526c..791553b0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -259,7 +259,41 @@ 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 (stop generating levels below this). Default: 512. */ + private int minRenderResolution = 512; + + /** Full-resolution height data (primitive). Derived from originalGrid or other data sources. */ + private HeightField fullResHeightField = null; + + /** 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; + + /** 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. */ + private LodManager lodManager = null; + +// 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; @@ -362,7 +396,8 @@ public Hypersurface3DPane(Scene scene) { double newZ = z + event.getZoomFactor() * modifierFactor * modifier; camera.setTranslateZ(newZ); updateLabels(); - }); + if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); +}); subScene.setOnKeyPressed(event -> { KeyCode keycode = event.getCode(); @@ -478,7 +513,8 @@ public Hypersurface3DPane(Scene scene) { camera.setTranslateZ(newZ); updateLabels(); updateCalloutHeadPoints(subScene); - }); + if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); +}); subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); Pane pathPane = App.getAppPathPaneStack(); @@ -494,7 +530,11 @@ public Hypersurface3DPane(Scene scene) { ApplicationEvent.SHOW_HYPERSPACE_CONTROLS, Boolean.TRUE)); }); - MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); + + subScene.setOnMouseReleased((MouseEvent me) -> { + if (lodManager != null) lodManager.forceUpdate(); + }); +MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); copyAsImageItem.setOnAction((ActionEvent e) -> { Clipboard clipboard = Clipboard.getSystemClipboard(); ClipboardContent content = new ClipboardContent(); @@ -647,7 +687,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 @@ -1255,21 +1295,36 @@ 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)); + 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)); + return SurfaceUtils.sample(activeHeightField, gx, gy, interpMode); } case NEAREST: default: @@ -1278,11 +1333,42 @@ private Number vertToHeight(Vert3D p) { } private Number lookupPoint(Vert3D p) { + if (activeHeightField != null) { + int w = activeHeightField.width(); + int h = activeHeightField.height(); + if (p.yIndex < 0 || p.yIndex >= h || p.xIndex < 0 || p.xIndex >= w) return 0.0; + return (double) activeHeightField.data()[p.yIndex * w + p.xIndex]; + } + if (dataGrid == null) return 0.0; if (p.yIndex >= dataGrid.size() || p.xIndex >= dataGrid.get(0).size()) return 0.0; return dataGrid.get(p.yIndex).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,7 +1390,6 @@ 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() { @@ -1318,6 +1403,27 @@ private void loadSurf3D() { surfPlot.setCullFace(CullFace.NONE); surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); surfPlot.setTranslateZ(-(zWidth * surfScale) / 2.0); + + + // Initialize LOD manager (screen-space selection + throttle/debounce scheduling) + if (lodManager == null) { + lodManager = new LodManager(camera, subScene, surfPlot); + LodManager.LodConfig cfg = new LodManager.LodConfig(); + cfg.targetPixelsPerCell = 1.5; + cfg.lowThreshold = 1.0; + cfg.highThreshold = 2.0; + cfg.throttleMs = 75; + cfg.debounceMs = 75; + lodManager.setConfig(cfg); + lodManager.setOnLodSelected(this::applyActiveLodIndex); + } + + // Cache full-res primitive field (used only for L0 resampling). + fullResHeightField = SurfaceUtils.toHeightField(originalGrid); + + // Build processed pyramid and apply initial LOD. + rebuildProcessedGridAndRefresh(); + surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { if (hoverInteractionsEnabled) { javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); @@ -2023,23 +2129,88 @@ private static List> deepCopyGrid(List> src) { } private void rebuildProcessedGridAndRefresh() { + // preprocess L0 then downsample processed. + // This ensures we never attempt to render full-res (e.g., 4K) directly into a JavaFX TriangleMesh. if (originalGrid == null || originalGrid.isEmpty()) return; - List> g = deepCopyGrid(originalGrid); - if (smoothingEnabled) { - g = SurfaceUtils.smooth(g, smoothingMethod, gaussianSigma, smoothingRadius); + + // Cache full-res primitive field (used only as a source for L0 resampling). + if (fullResHeightField == null) { + fullResHeightField = SurfaceUtils.toHeightField(originalGrid); } - if (toneEnabled) { - g = SurfaceUtils.toneMapGrid(g, toneOperator, toneParam); + + // Build capped L0 raw (aspect-preserving) from full-res + HeightField l0Raw = SurfaceUtils.buildL0Raw(fullResHeightField, maxRenderResolution); + + // Process L0 once, then build processed pyramid + SurfaceUtils.Smoothing sm = smoothingEnabled ? smoothingMethod : SurfaceUtils.Smoothing.NONE; + SurfaceUtils.ToneMap tm = toneEnabled ? toneOperator : SurfaceUtils.ToneMap.NONE; + + // Keep legacy behavior: single-pass smoothing (iterations=1). Can be exposed later. + int iterations = 1; + + HeightField l0Processed = SurfaceUtils.processL0( + l0Raw, + sm, + smoothingRadius, + iterations, + gaussianSigma, + tm, + toneParam + ); + + lodProcessedLevels = SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); + + // Establish constant world extents based on L0 and current surfScale. + baseWorldWidth = l0Processed.width() * surfScale; + baseWorldDepth = l0Processed.height() * surfScale; + + if (lodManager != null) { + lodManager.setPyramid( + LodManager.fromHeightFields(lodProcessedLevels), + baseWorldWidth, + baseWorldDepth, + activeLodIndex + ); + lodManager.forceUpdate(); + } else { + applyActiveLodIndex(0); } - dataGrid.clear(); - dataGrid.addAll(g); - xWidth = dataGrid.get(0).size(); - zWidth = dataGrid.size(); + syncGuiControls(); updateTheMesh(); 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; + + if (idx == activeLodIndex && activeHeightField != null) return; + + activeLodIndex = idx; + activeHeightField = lodProcessedLevels.get(idx); + + // Keep boxed dataGrid as active LOD for compatibility with existing code paths. + dataGrid = SurfaceUtils.toGrid(activeHeightField); + + xWidth = activeHeightField.width(); + zWidth = activeHeightField.height(); + + // Per-LOD cell size (world units per grid cell) to keep world extents stable. + currentLodSurfScaleX = (Double.isFinite(baseWorldWidth) && xWidth > 0) ? (baseWorldWidth / xWidth) : surfScale; + currentLodSurfScaleZ = (Double.isFinite(baseWorldDepth) && zWidth > 0) ? (baseWorldDepth / zWidth) : surfScale; + + if (surfPlot != null) { + surfPlot.setTranslateX(-(xWidth * currentLodSurfScaleX) / 2.0); + surfPlot.setTranslateZ(-(zWidth * currentLodSurfScaleZ) / 2.0); + } + } + /** * Build a dense similarity/divergence row for a node from the current sparse graph. */ 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..b0ef2534 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java @@ -0,0 +1,326 @@ +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 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 { + + 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); + } + + /** + * 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). 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(); + + if (next != activeIndex) { + 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 || force) { + 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()); + } + + // ============================================================ + // 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/SurfaceUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java index f1f3ea52..2a095c21 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,505 @@ 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); + 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; + } + + // ------------------------- + // 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"); + HeightField smoothed = smoothCopy(l0Raw, smoothing, radius, iterations, sigma); + return toneMapCopy(smoothed, toneMap, toneParam); + } +} \ No newline at end of file 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 { From 951a9b3831690fdd947f2622f72ce47d940be7ad Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Mon, 5 Oct 2026 23:55:00 -0400 Subject: [PATCH 02/13] fixed global LOD --- .../javafx/javafx3d/Hypersurface3DPane.java | 586 ++++++++++++++---- .../trinity/javafx/javafx3d/LodManager.java | 56 +- 2 files changed, 510 insertions(+), 132 deletions(-) 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 791553b0..79f39081 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -207,6 +207,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(); @@ -286,6 +292,15 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} 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; + 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; @@ -486,6 +501,7 @@ public Hypersurface3DPane(Scene scene) { updateLabels(); updateCalloutHeadPoints(subScene); + if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.OTHER); }); subScene.setOnMousePressed((MouseEvent me) -> { @@ -501,6 +517,7 @@ public Hypersurface3DPane(Scene scene) { else camera.setTranslateZ(camera.getTranslateZ() - 50.0); updateLabels(); updateCalloutHeadPoints(subScene); + if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); e.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { @@ -637,9 +654,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"); @@ -834,8 +850,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)); @@ -935,7 +952,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(); @@ -947,7 +964,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(); @@ -970,7 +987,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(); @@ -992,7 +1009,7 @@ public void computeVectorDistances() { }); dataGrid.clear(); dataGrid.addAll(distancesGrid); - originalGrid = deepCopyGrid(dataGrid); // NEW + captureDataGridAsSource(); xWidth = dataGrid.get(0).size(); zWidth = dataGrid.size(); syncGuiControls(); @@ -1054,13 +1071,27 @@ public void addCallout(Callout callout, Shape3D shape3D) { } public void updateTheMesh() { + if (surfPlot == null) return; + surfPlot.setVisible(surfaceRender); sceneRoot.getChildren().removeIf(n -> n instanceof TessellationTube); + + 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.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); @@ -1072,8 +1103,39 @@ 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; + + switch (colorationMethod) { + case COLOR_BY_IMAGE -> { + if (lastImage == null) return; + 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 -> + 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); @@ -1149,8 +1211,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) { @@ -1163,8 +1225,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); @@ -1237,6 +1301,7 @@ private void mouseDragCamera(MouseEvent me) { } updateLabels(); updateCalloutHeadPoints(subScene); + if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.DRAG); } private void updateLabels() { @@ -1395,7 +1460,7 @@ private Number quickBlerp(double f1, double f2, double f3, double f4, double x, 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.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); surfPlot.setDrawMode(DrawMode.LINE); @@ -1418,45 +1483,53 @@ private void loadSurf3D() { lodManager.setOnLodSelected(this::applyActiveLodIndex); } - // Cache full-res primitive field (used only for L0 resampling). - fullResHeightField = SurfaceUtils.toHeightField(originalGrid); - - // Build processed pyramid and apply initial LOD. + // 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) { javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); vertP3D = Point3D.convertFromJavaFXPoint3D(p3D); - highlightedPoint.setTranslateX(vertP3D.x - (xWidth * surfScale) / 2.0); + highlightedPoint.setTranslateX(vertP3D.x - getWorldWidth() / 2.0); highlightedPoint.setTranslateY(vertP3D.y); - highlightedPoint.setTranslateZ(vertP3D.z - (zWidth * surfScale) / 2.0); + highlightedPoint.setTranslateZ(vertP3D.z - getWorldDepth() / 2.0); updateCalloutHeadPoints(subScene); updateLabels(); - int row = Float.valueOf(vertP3D.getZ() / surfScale).intValue(); - int column = Float.valueOf(vertP3D.getX() / surfScale).intValue(); + + 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 sourceRow = mapRenderIndexToSource(row, getRenderHeight(), getSourceHeight()); + if (null != anchorCallout) { - if (row < featureVectors.size()) updateCalloutByFeatureVector(anchorCallout, featureVectors.get(row)); - setSpheroidAnchor(false, row); + if (sourceRow < featureVectors.size()) { + updateCalloutByFeatureVector(anchorCallout, featureVectors.get(sourceRow)); + } + setSpheroidAnchor(false, sourceRow); } if (crosshairsEnabled) { paintSingleColor(Color.TRANSPARENT); illuminateCrosshair(vertP3D); } - if (surfaceChartsEnabled) { - List xlist = dataGrid.get(Math.max(0, Math.min(row, dataGrid.size() - 1))); + if (surfaceChartsEnabled && activeHeightField != null) { + List xlist = getActiveRenderRow(row); 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))); + Double[] zRay = getActiveRenderColumn(column); 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("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).get(); + 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).get(); + 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).get(); + 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); @@ -1472,11 +1545,11 @@ private void loadSurf3D() { 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); @@ -1488,10 +1561,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()); @@ -1513,9 +1586,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); @@ -1581,7 +1654,9 @@ private void wireEventHandlers() { // Geometry / scale scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { 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); } @@ -1590,7 +1665,7 @@ private void wireEventHandlers() { scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { 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); } @@ -1605,12 +1680,7 @@ private void wireEventHandlers() { scene.addEventHandler(HypersurfaceEvent.SURF_SCALE_CHANGED, e -> { 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(); }); @@ -1627,7 +1697,7 @@ private void wireEventHandlers() { }); scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { this.colorationMethod = (Hypersurface3DPane.COLORATION) e.object; - updateTheMesh(); + applyCurrentColoration(); }); // Processing pipeline @@ -1806,6 +1876,7 @@ public void clearAll() { dataGrid.clear(); featureVectors.clear(); originalGrid.clear(); + resetLodDataState(); } public void showAll() { @@ -1914,24 +1985,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); + 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(); } @@ -1969,7 +2040,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(); @@ -1979,7 +2050,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(); } @@ -1993,6 +2064,7 @@ public void clearFeatureVectors() { featureVectors.clear(); dataGrid.clear(); originalGrid.clear(); + resetLodDataState(); } @Override @@ -2038,45 +2110,73 @@ public void setSpheroidAnchor(boolean animate, int index) { 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); + System.out.println("Mapping Image Raster directly to primitive HeightField: " + + sourceWidth + "x" + sourceHeight); + + PixelReader pixelReader = image.getPixelReader(); + if (pixelReader == null) { + LOG.warn("Unable to tessellate image because PixelReader is unavailable."); + return; + } + + float[] heights = new float[Math.multiplyExact(sourceWidth, sourceHeight)]; + int targetIndex = 0; + for (int row = startY; row < endY; row++) { + for (int column = startX; column < endX; column++) { + int argb = pixelReader.getArgb(column, row); + int red = (argb >> 16) & 0xFF; + int green = (argb >> 8) & 0xFF; + int blue = argb & 0xFF; + heights[targetIndex++] = (float) (((red + green + blue) / 3.0) / 255.0); } - dataGrid.add(currentDataRow); } + + // Image-backed hypersurfaces use the primitive HeightField as the authoritative + // source. Do not manufacture one FeatureVector and one 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; + fullResHeightField = new HeightField(sourceWidth, sourceHeight, heights); + + // 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; + 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); } @@ -2128,31 +2228,247 @@ private static List> deepCopyGrid(List> src) { return out; } - private void rebuildProcessedGridAndRefresh() { - // preprocess L0 then downsample processed. - // This ensures we never attempt to render full-res (e.g., 4K) directly into a JavaFX TriangleMesh. - if (originalGrid == null || originalGrid.isEmpty()) return; + /** + * 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. + imageBackedSurface = false; + if (dataGrid == null || dataGrid.isEmpty()) { + originalGrid = new ArrayList<>(); + resetLodDataState(); + return; + } + originalGrid = deepCopyGrid(dataGrid); + fullResHeightField = null; + } + + private void resetLodDataState() { + imageBackedSurface = false; + fullResHeightField = null; + lodProcessedLevels = List.of(); + activeLodIndex = -1; + activeHeightField = null; + baseWorldWidth = Double.NaN; + baseWorldDepth = Double.NaN; + currentLodSurfScaleX = Double.NaN; + currentLodSurfScaleZ = Double.NaN; + } + + private int getSourceWidth() { + if (fullResHeightField != null) return fullResHeightField.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 (fullResHeightField != null) return fullResHeightField.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); + } - // Cache full-res primitive field (used only as a source for L0 resampling). + 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 = Math.max(0, Math.min(row, activeHeightField.height() - 1)); + 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 = Math.max(0, Math.min(row, activeHeightField.height() - 1)); + 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 y = 0; y < h; y++) out[y] = (double) values[y * 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 (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 && fullResHeightField != null) { + refreshWorldExtentsAndLodMetadata(); + 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; + 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 void rebuildProcessedGridAndRefresh() { + // Option B: cap/resample full-resolution source to L0, process L0 once, + // then derive all lower LODs from that processed L0. + // + // Image-backed surfaces may already provide the authoritative source directly as + // a primitive HeightField. Boxed-grid-backed surfaces continue to populate + // originalGrid and are converted lazily here for compatibility. if (fullResHeightField == null) { + if (originalGrid == null || originalGrid.isEmpty()) return; fullResHeightField = SurfaceUtils.toHeightField(originalGrid); } - // Build capped L0 raw (aspect-preserving) from full-res HeightField l0Raw = SurfaceUtils.buildL0Raw(fullResHeightField, maxRenderResolution); - // Process L0 once, then build processed pyramid - SurfaceUtils.Smoothing sm = smoothingEnabled ? smoothingMethod : SurfaceUtils.Smoothing.NONE; - SurfaceUtils.ToneMap tm = toneEnabled ? toneOperator : SurfaceUtils.ToneMap.NONE; - - // Keep legacy behavior: single-pass smoothing (iterations=1). Can be exposed later. - int iterations = 1; + SurfaceUtils.Smoothing sm = smoothingEnabled + ? smoothingMethod + : SurfaceUtils.Smoothing.NONE; + SurfaceUtils.ToneMap tm = toneEnabled + ? toneOperator + : SurfaceUtils.ToneMap.NONE; HeightField l0Processed = SurfaceUtils.processL0( l0Raw, sm, smoothingRadius, - iterations, + 1, gaussianSigma, tm, toneParam @@ -2160,24 +2476,30 @@ private void rebuildProcessedGridAndRefresh() { lodProcessedLevels = SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); - // Establish constant world extents based on L0 and current surfScale. - baseWorldWidth = l0Processed.width() * surfScale; - baseWorldDepth = l0Processed.height() * surfScale; + // 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 (lodManager != null) { lodManager.setPyramid( LodManager.fromHeightFields(lodProcessedLevels), baseWorldWidth, baseWorldDepth, - activeLodIndex + -1 ); lodManager.forceUpdate(); } else { applyActiveLodIndex(0); } - syncGuiControls(); - updateTheMesh(); updateView(true); } @@ -2190,25 +2512,31 @@ private void applyActiveLodIndex(int idx) { if (idx < 0) idx = 0; if (idx >= lodProcessedLevels.size()) idx = lodProcessedLevels.size() - 1; - if (idx == activeLodIndex && activeHeightField != null) return; + HeightField selected = lodProcessedLevels.get(idx); + if (idx == activeLodIndex && activeHeightField == selected) return; activeLodIndex = idx; - activeHeightField = lodProcessedLevels.get(idx); - - // Keep boxed dataGrid as active LOD for compatibility with existing code paths. - dataGrid = SurfaceUtils.toGrid(activeHeightField); + 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(); - // Per-LOD cell size (world units per grid cell) to keep world extents stable. - currentLodSurfScaleX = (Double.isFinite(baseWorldWidth) && xWidth > 0) ? (baseWorldWidth / xWidth) : surfScale; - currentLodSurfScaleZ = (Double.isFinite(baseWorldDepth) && zWidth > 0) ? (baseWorldDepth / zWidth) : surfScale; + 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(-(xWidth * currentLodSurfScaleX) / 2.0); - surfPlot.setTranslateZ(-(zWidth * currentLodSurfScaleZ) / 2.0); + surfPlot.setTranslateX(-baseWorldWidth / 2.0); + surfPlot.setTranslateZ(-baseWorldDepth / 2.0); } + + syncGuiControls(); + updateTheMesh(); } /** @@ -2290,10 +2618,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 index b0ef2534..dbc8b00f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodManager.java @@ -7,6 +7,8 @@ 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; @@ -27,6 +29,8 @@ */ public final class LodManager { + private static final Logger LOG = LoggerFactory.getLogger(LodManager.class); + public enum UpdateReason { DRAG, SCROLL, @@ -165,6 +169,10 @@ public void setPyramid(List levels, double baseWorldWidth, double base 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); } /** @@ -209,7 +217,8 @@ public void requestUpdate(UpdateReason reason) { } /** - * Force an immediate evaluation (no throttle). Safe to call from any thread. + * 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(() -> { @@ -243,7 +252,12 @@ private void evaluateAndNotify(boolean force) { lastEvalNanos = System.nanoTime(); - if (next != activeIndex) { + boolean changed = next != activeIndex; + if (force || changed) { + logEvaluation(next, force, changed); + } + + if (changed) { activeIndex = next; if (onLodSelected != null) { onLodSelected.accept(next); @@ -262,7 +276,7 @@ private int chooseIndexScreenSpace(boolean force) { if (!(denom > 1e-12)) return (activeIndex >= 0 ? activeIndex : 0); // If no current index, pick based on target threshold (finest meeting target) - if (activeIndex < 0 || force) { + 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); @@ -309,6 +323,42 @@ private double computeDepthToSurfaceCenter() { 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 // ============================================================ From 64cdec75d5a607a2b637fd3ad060e3113b040972 Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 6 Oct 2026 00:12:09 -0400 Subject: [PATCH 03/13] fixed color by feature --- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 350 ++++++++++++++++++ .../javafx/javafx3d/Hypersurface3DPane.java | 87 ++++- 2 files changed, 420 insertions(+), 17 deletions(-) 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..6681fd4b 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; @@ -68,6 +73,22 @@ public class HyperSurfacePlotMesh extends TexturedMesh { 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; + public HyperSurfacePlotMesh() { this(DEFAULT_FUNCTION, DEFAULT_X_RANGE, DEFAULT_Y_RANGE, DEFAULT_X_DIVISIONS, DEFAULT_Y_DIVISIONS, DEFAULT_FUNCTION_SCALE); } @@ -117,11 +138,23 @@ public HyperSurfacePlotMesh(int rangeX, int rangeY, int divisionsX, int division } 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 +162,131 @@ 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); + } + + 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 rendered Y value using a compact rainbow + * palette. Only texture coordinates are updated; point and face buffers are retained. + */ + 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 = Math.min(directStartZ + localZ, sourceHeight - 1); + 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 +297,7 @@ protected final void updateMeshSmooth(int rangeX, int rangeY) { @Override protected final void updateMesh() { + clearDirectHeightFieldState(); setMesh(null); mesh = createPlotMesh( getFunction2D(), @@ -330,6 +483,203 @@ 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); + triangleMesh.getFaces().resize((int) faceIntCount); + triangleMesh.getFaceSmoothingGroups().resize((int) faceCount); + + 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 = Math.min(globalVertexZ, sourceHeight - 1); + final int sourceRow = sampleZ * sourceWidth; + final float worldZ = (float) (localZ * zScale); + final float v = (float) globalVertexZ / (float) 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 = source[sourceRow + sampleX] * (float) 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); + } + + // Bound temporary allocations while populating the very large persistent JavaFX + // face buffers. Point and texture indices intentionally match one-to-one. + final int rowsPerBlock = 16; + for (int z0 = 0; z0 < cellsZ; z0 += rowsPerBlock) { + final int rows = Math.min(rowsPerBlock, cellsZ - z0); + final int trianglesInBlock = Math.multiplyExact(Math.multiplyExact(rows, cellsX), 2); + final int[] faces = new int[Math.multiplyExact(trianglesInBlock, 6)]; + final int[] groups = new int[trianglesInBlock]; + Arrays.fill(groups, 1); + + int fi = 0; + for (int localZ = 0; localZ < rows; localZ++) { + final int z = z0 + localZ; + final int rowStart = z * vertsX; + for (int x = 0; x < cellsX; x++) { + final int p00 = rowStart + x; + final int p01 = p00 + 1; + final int p10 = p00 + vertsX; + final int p11 = p10 + 1; + + faces[fi++] = p00; + faces[fi++] = p00; + faces[fi++] = p10; + faces[fi++] = p10; + faces[fi++] = p11; + faces[fi++] = p11; + + faces[fi++] = p11; + faces[fi++] = p11; + faces[fi++] = p01; + faces[fi++] = p01; + faces[fi++] = p00; + faces[fi++] = p00; + } + } + + final int firstTriangle = z0 * cellsX * 2; + triangleMesh.getFaces().set(firstTriangle * 6, + faces, 0, faces.length); + triangleMesh.getFaceSmoothingGroups().set(firstTriangle, + groups, 0, groups.length); + } + + return triangleMesh; + } + + 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 = (float) globalVertexZ / (float) 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 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(); 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 79f39081..4989784e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -1082,7 +1082,23 @@ public void updateTheMesh() { final double renderScaleZ = getRenderScaleZ(); if (surfaceRender) { - surfPlot.updateMeshRaw(renderWidth, renderHeight, renderScaleX, yScale, renderScaleZ); + // Primitive HeightField meshes bypass the FXyz Point3D/Face3 object graph. + // Shapley coloration temporarily retains the legacy path until its scalar + // field is made LOD-aware; image and height coloration use the fast path. + boolean useDirectHeightFieldMesh = activeHeightField != null + && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; + + long meshStart = System.nanoTime(); + if (useDirectHeightFieldMesh) { + surfPlot.updateMeshHeightField(activeHeightField, + renderScaleX, yScale, renderScaleZ); + System.out.println("Direct HeightField mesh built: " + + renderWidth + "x" + renderHeight + " in " + + Utils.totalTimeString(meshStart)); + } else { + surfPlot.updateMeshRaw(renderWidth, renderHeight, + renderScaleX, yScale, renderScaleZ); + } applyCurrentColoration(); } else { // Cylinder/tube mode still needs the legacy boxed representation, but it must @@ -1114,18 +1130,46 @@ private void applyCurrentColoration() { switch (colorationMethod) { case COLOR_BY_IMAGE -> { if (lastImage == null) return; - PhongMaterial material; - if (surfPlot.getMaterial() instanceof PhongMaterial existing) { - material = existing; + if (surfPlot.isDirectHeightFieldMesh()) { + surfPlot.setDirectTextureModeImage(lastImage); } else { - material = new PhongMaterial(Color.WHITE); + 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); } - material.setDiffuseColor(Color.WHITE); - material.setDiffuseMap(lastImage); - surfPlot.setMaterial(material); } - case COLOR_BY_FEATURE -> - surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); case COLOR_BY_SHAPLEY -> surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByShapley, 0.0, 360.0); } @@ -1696,8 +1740,17 @@ private void wireEventHandlers() { if (surfPlot != null) surfPlot.setCullFace((CullFace) e.object); }); scene.addEventHandler(HypersurfaceEvent.COLORATION_CHANGED, e -> { - this.colorationMethod = (Hypersurface3DPane.COLORATION) e.object; - applyCurrentColoration(); + 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) updateTheMesh(); + else applyCurrentColoration(); }); // Processing pipeline @@ -2199,11 +2252,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) { From 1ad3f30c56acae421a77af73253673b2ad17a03f Mon Sep 17 00:00:00 2001 From: Sean Phillips Date: Tue, 6 Oct 2026 04:26:04 -0400 Subject: [PATCH 04/13] color by feature fixed --- .../javafx/javafx3d/Hypersurface3DPane.java | 333 +++++++++++++---- .../javafx/javafx3d/TiledLodManager.java | 349 ++++++++++++++++++ .../javafx/javafx3d/TiledSurfaceRenderer.java | 343 +++++++++++++++++ 3 files changed, 955 insertions(+), 70 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java 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 4989784e..eedc4ed0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -305,9 +305,14 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} private double currentLodSurfScaleX = Double.NaN; private double currentLodSurfScaleZ = Double.NaN; - /** Screen-space LOD scheduler/selector. */ + /** 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; @@ -411,7 +416,7 @@ public Hypersurface3DPane(Scene scene) { double newZ = z + event.getZoomFactor() * modifierFactor * modifier; camera.setTranslateZ(newZ); updateLabels(); - if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); + requestLodUpdate(LodManager.UpdateReason.SCROLL); }); subScene.setOnKeyPressed(event -> { @@ -501,7 +506,7 @@ public Hypersurface3DPane(Scene scene) { updateLabels(); updateCalloutHeadPoints(subScene); - if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.OTHER); + requestLodUpdate(LodManager.UpdateReason.OTHER); }); subScene.setOnMousePressed((MouseEvent me) -> { @@ -517,7 +522,7 @@ public Hypersurface3DPane(Scene scene) { else camera.setTranslateZ(camera.getTranslateZ() - 50.0); updateLabels(); updateCalloutHeadPoints(subScene); - if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); + requestLodUpdate(LodManager.UpdateReason.SCROLL); e.consume(); }); subScene.setOnScroll((ScrollEvent event) -> { @@ -530,7 +535,7 @@ public Hypersurface3DPane(Scene scene) { camera.setTranslateZ(newZ); updateLabels(); updateCalloutHeadPoints(subScene); - if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.SCROLL); + requestLodUpdate(LodManager.UpdateReason.SCROLL); }); subScene.setOnMouseDragged((MouseEvent me) -> mouseDragCamera(me)); @@ -549,7 +554,7 @@ public Hypersurface3DPane(Scene scene) { subScene.setOnMouseReleased((MouseEvent me) -> { - if (lodManager != null) lodManager.forceUpdate(); + forceLodUpdate(); }); MenuItem copyAsImageItem = new MenuItem("Copy Scene to Clipboard"); copyAsImageItem.setOnAction((ActionEvent e) -> { @@ -1073,9 +1078,23 @@ public void addCallout(Callout callout, Shape3D shape3D) { public void updateTheMesh() { if (surfPlot == null) return; - surfPlot.setVisible(surfaceRender); 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; + } + + surfPlot.setVisible(surfaceRender); + if (paintMeshView != null) paintMeshView.setVisible(true); + final int renderWidth = getRenderWidth(); final int renderHeight = getRenderHeight(); final double renderScaleX = getRenderScaleX(); @@ -1126,6 +1145,10 @@ public void updateTheMesh() { */ private void applyCurrentColoration() { if (surfPlot == null || colorationMethod == null) return; + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledAppearance(); + return; + } switch (colorationMethod) { case COLOR_BY_IMAGE -> { @@ -1345,7 +1368,7 @@ private void mouseDragCamera(MouseEvent me) { } updateLabels(); updateCalloutHeadPoints(subScene); - if (lodManager != null) lodManager.requestUpdate(LodManager.UpdateReason.DRAG); + requestLodUpdate(LodManager.UpdateReason.DRAG); } private void updateLabels() { @@ -1357,6 +1380,59 @@ 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 sourceRow = mapRenderIndexToSource(row, 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 { @@ -1524,7 +1600,26 @@ private void loadSurf3D() { cfg.throttleMs = 75; cfg.debounceMs = 75; lodManager.setConfig(cfg); - lodManager.setOnLodSelected(this::applyActiveLodIndex); + lodManager.setOnLodSelected(idx -> { + if (!shouldUseTiledHeightFieldRenderer()) applyActiveLodIndex(idx); + }); + } + + if (tiledSurfaceRenderer == null) { + tiledSurfaceRenderer = new TiledSurfaceRenderer(camera, subScene); + tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); + tiledSurfaceRenderer.setVisible(false); + 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 @@ -1532,58 +1627,11 @@ private void loadSurf3D() { rebuildProcessedGridAndRefresh(); surfPlot.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { - if (hoverInteractionsEnabled) { - javafx.geometry.Point3D p3D = e.getPickResult().getIntersectedPoint(); - vertP3D = Point3D.convertFromJavaFXPoint3D(p3D); - 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 sourceRow = mapRenderIndexToSource(row, getRenderHeight(), getSourceHeight()); - - if (null != anchorCallout) { - if (sourceRow < featureVectors.size()) { - updateCalloutByFeatureVector(anchorCallout, featureVectors.get(sourceRow)); - } - setSpheroidAnchor(false, sourceRow); - } - if (crosshairsEnabled) { - 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)); - } - e.consume(); - } + 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); @@ -1647,12 +1695,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(); @@ -1734,10 +1782,14 @@ 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 -> { COLORATION previous = this.colorationMethod; @@ -1749,8 +1801,24 @@ private void wireEventHandlers() { // coloration only changes UV/material state on the direct mesh. boolean shapleyPathChanged = (previous == COLORATION.COLOR_BY_SHAPLEY) != (next == COLORATION.COLOR_BY_SHAPLEY); - if (shapleyPathChanged) updateTheMesh(); - else applyCurrentColoration(); + 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 @@ -1802,8 +1870,10 @@ 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 @@ -2309,6 +2379,10 @@ private void resetLodDataState() { baseWorldDepth = Double.NaN; currentLodSurfScaleX = Double.NaN; currentLodSurfScaleZ = Double.NaN; + if (tiledSurfaceRenderer != null) { + tiledSurfaceRenderer.clearSurface(); + tiledSurfaceRenderer.setVisible(false); + } } private int getSourceWidth() { @@ -2429,7 +2503,10 @@ private void refreshWorldExtentsAndLodMetadata() { surfPlot.setTranslateX(-baseWorldWidth / 2.0); surfPlot.setTranslateZ(-baseWorldDepth / 2.0); } - if (lodManager != null && lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + tiledSurfaceRenderer.forceUpdate(); + } else if (lodManager != null && lodProcessedLevels != null && !lodProcessedLevels.isEmpty()) { lodManager.setPyramid( LodManager.fromHeightFields(lodProcessedLevels), baseWorldWidth, @@ -2464,6 +2541,44 @@ public void setNominalImageWorldExtent(double nominalImageWorldExtent) { } } + 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 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; } @@ -2496,6 +2611,81 @@ public void setMinRenderResolution(int minRenderResolution) { rebuildProcessedGridAndRefresh(); } + private boolean shouldUseTiledHeightFieldRenderer() { + return tiledHeightFieldRenderingEnabled + && imageBackedSurface + && tiledSurfaceRenderer != null + && lodProcessedLevels != null + && !lodProcessedLevels.isEmpty() + && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; + } + + private 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); + + 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() { // Option B: cap/resample full-resolution source to L0, process L0 once, // then derive all lower LODs from that processed L0. @@ -2541,7 +2731,10 @@ private void rebuildProcessedGridAndRefresh() { currentLodSurfScaleX = Double.NaN; currentLodSurfScaleZ = Double.NaN; - if (lodManager != null) { + if (shouldUseTiledHeightFieldRenderer()) { + configureTiledPyramid(); + updateTheMesh(); + } else if (lodManager != null) { lodManager.setPyramid( LodManager.fromHeightFields(lodProcessedLevels), baseWorldWidth, 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..15188608 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -0,0 +1,349 @@ +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 java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Camera/frustum-aware per-tile LOD selector for a tiled heightfield surface. + * + *

This class performs no mesh construction. It owns only tile visibility/LOD + * decisions plus the same leading-throttle/trailing-debounce scheduling used by + * the original global {@link LodManager}.

+ */ +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); } + } + + public record TileDecision(int tileId, boolean visible, int lodIndex, + double depth, double pixelsPerCell) { } + + public static final class Config { + public double targetPixelsPerCell = 1.5; + public double lowThreshold = 1.0; + public double highThreshold = 2.0; + 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; + + public Config copy() { + Config c = new Config(); + c.targetPixelsPerCell = targetPixelsPerCell; + c.lowThreshold = lowThreshold; + c.highThreshold = highThreshold; + c.throttleMs = throttleMs; + c.debounceMs = debounceMs; + c.minDepth = minDepth; + c.frustumMargin = frustumMargin; + c.maxNeighborLodDelta = maxNeighborLodDelta; + return c; + } + } + + private final PerspectiveCamera camera; + private final SubScene subScene; + private final Node surfaceNode; + private final PauseTransition debounceTimer; + private final javafx.beans.value.ChangeListener resizeListener = + (obs, oldValue, newValue) -> requestUpdate(LodManager.UpdateReason.RESIZE); + + private Config config = new Config(); + private List levels = List.of(); + private List tiles = List.of(); + private double baseWorldWidth = Double.NaN; + private double baseWorldDepth = Double.NaN; + private double heightRadius = 0.0; + private long lastEvalNanos; + private Consumer> onDecisions; + + /** Current per-tile LOD state. Invisible tiles keep their last LOD for hysteresis. */ + private final Map activeLodByTile = new HashMap<>(); + + 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 -> evaluateAndNotify(false)); + subScene.widthProperty().addListener(resizeListener); + subScene.heightProperty().addListener(resizeListener); + } + + public void setConfig(Config config) { + this.config = Objects.requireNonNull(config, "config").copy(); + runOnFx(() -> debounceTimer.setDuration(Duration.millis(this.config.debounceMs))); + } + + public Config getConfigCopy() { + return config.copy(); + } + + public void setOnDecisions(Consumer> onDecisions) { + this.onDecisions = onDecisions; + } + + 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); + activeLodByTile.clear(); + } + + public void requestUpdate(LodManager.UpdateReason reason) { + runOnFx(() -> { + debounceTimer.stop(); + debounceTimer.setDuration(Duration.millis(config.debounceMs)); + debounceTimer.playFromStart(); + + 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 while preserving per-tile hysteresis. */ + public void forceUpdate() { + runOnFx(() -> { + debounceTimer.stop(); + evaluateAndNotify(true); + }); + } + + public void dispose() { + runOnFx(() -> { + debounceTimer.stop(); + subScene.widthProperty().removeListener(resizeListener); + }); + } + + private void evaluateAndNotify(boolean force) { + if (levels.isEmpty() || tiles.isEmpty()) return; + if (!Double.isFinite(baseWorldWidth) || !Double.isFinite(baseWorldDepth)) return; + + final double viewportWidth = Math.max(1.0, subScene.getWidth()); + final double viewportHeight = Math.max(1.0, subScene.getHeight()); + 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; + + ArrayList decisions = new ArrayList<>(tiles.size()); + Map visibleLods = new HashMap<>(); + + for (TileSpec tile : tiles) { + CameraTile cameraTile = cameraTile(tile); + boolean visible = isVisible(cameraTile, tile, aspect, tanHalfFov); + int current = activeLodByTile.getOrDefault(tile.id, -1); + + if (!visible) { + decisions.add(new TileDecision(tile.id, false, + current >= 0 ? current : levels.size() - 1, + cameraTile.depth, 0.0)); + continue; + } + + double depth = Math.max(config.minDepth, + cameraTile.depth - cameraTile.radius * 0.35); + int selected = chooseLod(current, depth, viewportWidth, viewportHeight, aspect, tanHalfFov); + activeLodByTile.put(tile.id, selected); + visibleLods.put(gridKey(tile.column, tile.row), selected); + decisions.add(new TileDecision(tile.id, true, selected, depth, + pixelsPerCell(levels.get(selected), depth, viewportWidth, viewportHeight, aspect, tanHalfFov))); + } + + // Prevent abrupt neighboring resolution differences. We only refine the coarser + // neighbor; never coarsen a tile selected by screen-space demand. + if (config.maxNeighborLodDelta >= 0) { + enforceNeighborDelta(decisions, visibleLods); + } + + lastEvalNanos = System.nanoTime(); + if (onDecisions != null) onDecisions.accept(List.copyOf(decisions)); + } + + private int chooseLod(int current, double depth, + double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + if (current < 0 || current >= levels.size()) { + int best = levels.size() - 1; + for (int i = 0; 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 > 0) { + double ppc = pixelsPerCell(levels.get(selected), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + if (ppc <= config.highThreshold) break; + selected--; + } + return 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 CameraTile cameraTile(TileSpec tile) { + Point3D scenePoint = surfaceNode.localToScene(tile.centerX(), 0.0, tile.centerZ()); + Point3D cameraPoint = camera.sceneToLocal(scenePoint); + double radius = Math.sqrt(tile.halfWidth() * tile.halfWidth() + + tile.halfDepth() * tile.halfDepth() + + heightRadius * heightRadius) * Math.max(1.0, config.frustumMargin); + return new CameraTile(cameraPoint.getX(), cameraPoint.getY(), cameraPoint.getZ(), radius); + } + + private boolean isVisible(CameraTile ct, TileSpec tile, double aspect, double tanHalfFov) { + double z = ct.depth; + double radius = ct.radius; + 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(ct.x) <= halfWidth + radius + && Math.abs(ct.y) <= halfHeight + radius; + } + + private void enforceNeighborDelta(List decisions, Map visibleLods) { + if (visibleLods.isEmpty()) return; + Map byId = new HashMap<>(); + for (TileDecision d : decisions) byId.put(d.tileId(), d); + + boolean changed; + int guard = Math.max(1, levels.size() * 2); + do { + changed = false; + for (TileSpec tile : tiles) { + TileDecision decision = byId.get(tile.id); + if (decision == null || !decision.visible()) continue; + int lod = activeLodByTile.getOrDefault(tile.id, decision.lodIndex()); + int refined = lod; + refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column - 1, tile.row))); + refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column + 1, tile.row))); + refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column, tile.row - 1))); + refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column, tile.row + 1))); + if (refined != lod) { + activeLodByTile.put(tile.id, refined); + visibleLods.put(gridKey(tile.column, tile.row), refined); + changed = true; + } + } + } while (changed && --guard > 0); + + for (int i = 0; i < decisions.size(); i++) { + TileDecision d = decisions.get(i); + if (!d.visible()) continue; + int lod = activeLodByTile.getOrDefault(d.tileId(), d.lodIndex()); + if (lod != d.lodIndex()) { + double ppc = d.pixelsPerCell(); + decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), ppc)); + } + } + } + + private int refineAgainstNeighbor(int lod, Integer neighborLod) { + if (neighborLod == null) return lod; + int maxAllowed = neighborLod + config.maxNeighborLodDelta; + return lod > maxAllowed ? maxAllowed : lod; + } + + private static long gridKey(int column, int row) { + return (((long) column) << 32) ^ (row & 0xffffffffL); + } + + private static void runOnFx(Runnable runnable) { + if (Platform.isFxApplicationThread()) runnable.run(); + else Platform.runLater(runnable); + } + + private record CameraTile(double x, double y, double depth, double radius) { } +} 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..ea12dd8d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -0,0 +1,343 @@ +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Tiled renderer for primitive HeightField LOD pyramids. + * + *

Tiles are defined in the finest rendered level (L0) and keep constant world + * footprints. Each visible tile independently selects a pyramid level through + * {@link TiledLodManager}. Meshes are created lazily and cached while culled.

+ */ +public final class TiledSurfaceRenderer extends Group { + + public enum ColorMode { + IMAGE, + HEIGHT + } + + private static final int DEFAULT_TILE_CELLS_L0 = 256; + + private final PerspectiveCamera camera; + private final SubScene subScene; + private final TiledLodManager lodManager; + private final Map tilesById = new HashMap<>(); + + 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.NONE; + 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; + + 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.setOnDecisions(this::applyDecisions); + setPickOnBounds(false); + } + + public void setTileCellsL0(int tileCellsL0) { + if (tileCellsL0 < 16) throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + if (this.tileCellsL0 == tileCellsL0) return; + this.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() { + tilesById.clear(); + tileSpecs = List.of(); + levels = List.of(); + getChildren().clear(); + } + + 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(); + clearSurface(); + } + + public void setYScale(double yScale) { + if (Double.compare(this.yScale, yScale) == 0) return; + this.yScale = yScale; + for (SurfaceTile tile : tilesById.values()) tile.currentLod = -1; + forceUpdate(); + } + + public void setDrawMode(DrawMode drawMode) { + this.drawMode = Objects.requireNonNull(drawMode, "drawMode"); + for (SurfaceTile tile : tilesById.values()) { + if (tile.mesh != null) tile.mesh.setDrawMode(drawMode); + } + } + + public void setCullFace(CullFace cullFace) { + this.cullFace = Objects.requireNonNull(cullFace, "cullFace"); + for (SurfaceTile tile : tilesById.values()) { + if (tile.mesh != null) tile.mesh.setCullFace(cullFace); + } + } + + public void setSpecularColor(Color specularColor) { + this.specularColor = Objects.requireNonNull(specularColor, "specularColor"); + for (SurfaceTile tile : tilesById.values()) applySpecular(tile); + } + + public void setColorByImage(Image image) { + this.image = image; + this.colorMode = ColorMode.IMAGE; + for (SurfaceTile tile : tilesById.values()) applyColoration(tile); + } + + 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; + for (SurfaceTile tile : tilesById.values()) applyColoration(tile); + } + + public int getVisibleTileCount() { + return visibleTileCount; + } + + public int getTotalTileCount() { + return tileSpecs.size(); + } + + public int getLastRebuildCount() { + return lastRebuildCount; + } + + private void rebuildTileLayout() { + getChildren().clear(); + tilesById.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)); + + ArrayList specs = new ArrayList<>(columns * rows); + int id = 0; + for (int row = 0; row < rows; row++) { + int startZ = row * tileCellsL0; + int endZ = Math.min(l0Height, startZ + tileCellsL0); + double minZ = startZ * baseWorldDepth / l0Height; + double maxZ = endZ * baseWorldDepth / l0Height; + + for (int column = 0; column < columns; column++) { + int startX = column * tileCellsL0; + int endX = Math.min(l0Width, startX + tileCellsL0); + double minX = startX * baseWorldWidth / l0Width; + double maxX = endX * baseWorldWidth / l0Width; + + TiledLodManager.TileSpec spec = new TiledLodManager.TileSpec( + id, column, row, minX, maxX, minZ, maxZ); + specs.add(spec); + tilesById.put(id, new SurfaceTile(spec, startX, endX, startZ, endZ)); + 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 + ); + + System.out.println("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + + " (" + tileSpecs.size() + "), tileCellsL0=" + tileCellsL0 + + ", world=" + baseWorldWidth + "x" + baseWorldDepth); + } + + private void applyDecisions(List decisions) { + int visible = 0; + int rebuilt = 0; + int[] lodCounts = new int[levels.size()]; + + for (TiledLodManager.TileDecision decision : decisions) { + SurfaceTile tile = tilesById.get(decision.tileId()); + if (tile == null) continue; + + if (!decision.visible()) { + if (tile.mesh != null) tile.mesh.setVisible(false); + continue; + } + + visible++; + int lod = Math.max(0, Math.min(decision.lodIndex(), levels.size() - 1)); + lodCounts[lod]++; + if (tile.mesh == null || tile.currentLod != lod) { + rebuildTile(tile, lod); + rebuilt++; + } + tile.mesh.setVisible(isVisible()); + } + + visibleTileCount = visible; + lastRebuildCount = rebuilt; + if (rebuilt > 0) { + StringBuilder sb = new StringBuilder("Tiled Hypersurface LOD: visible=") + .append(visible).append('/').append(tileSpecs.size()) + .append(", rebuilt=").append(rebuilt).append(", "); + for (int i = 0; i < lodCounts.length; i++) { + if (i > 0) sb.append(", "); + sb.append("L").append(i).append('=').append(lodCounts[i]); + } + System.out.println(sb); + } + } + + private void rebuildTile(SurfaceTile tile, int lod) { + HeightField field = levels.get(lod); + HeightField l0 = levels.get(0); + + // Map the constant L0 tile footprint onto the selected pyramid level. + int startX = mapBoundary(tile.startX0, l0.width(), field.width()); + int endX = mapBoundary(tile.endX0, l0.width(), field.width()); + int startZ = mapBoundary(tile.startZ0, l0.height(), field.height()); + int endZ = mapBoundary(tile.endZ0, l0.height(), 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(); + + if (tile.mesh == null) { + tile.mesh = new HyperSurfacePlotMesh(1, 1, 1, 1, + yScale, 1.0, p -> 0.0); + tile.mesh.setDrawMode(drawMode); + tile.mesh.setCullFace(cullFace); + getChildren().add(tile.mesh); + } + + tile.mesh.updateMeshHeightField(field, startX, startZ, + cellsX, cellsZ, scaleX, yScale, scaleZ); + tile.mesh.setTranslateX(startX * scaleX); + tile.mesh.setTranslateZ(startZ * scaleZ); + tile.currentLod = lod; + applyColoration(tile); + applySpecular(tile); + } + + private void applyColoration(SurfaceTile tile) { + if (tile.mesh == null || !tile.mesh.isDirectHeightFieldMesh()) return; + if (colorMode == ColorMode.IMAGE) { + if (image != null) tile.mesh.setDirectTextureModeImage(image); + } else { + tile.mesh.setDirectTextureModeByHeight(paletteColors, colorMin, colorMax); + } + } + + private void applySpecular(SurfaceTile tile) { + if (tile.mesh == null) return; + if (tile.mesh.getMaterial() instanceof PhongMaterial material) { + material.setSpecularColor(specularColor); + } + } + + 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); + } + + private static final class SurfaceTile { + final TiledLodManager.TileSpec spec; + final int startX0; + final int endX0; + final int startZ0; + final int endZ0; + HyperSurfacePlotMesh mesh; + int currentLod = -1; + + SurfaceTile(TiledLodManager.TileSpec spec, + int startX0, int endX0, + int startZ0, int endZ0) { + this.spec = spec; + this.startX0 = startX0; + this.endX0 = endX0; + this.startZ0 = startZ0; + this.endZ0 = endZ0; + } + } +} From f59b0cd57576785e37f616a24deb781050800715 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 05:59:34 -0400 Subject: [PATCH 05/13] Camera viewport relative LOD plus visibility based reveals --- .../panes/HypersurfaceControlsPane.java | 239 ++++++- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 35 ++ .../javafx/javafx3d/Hypersurface3DPane.java | 20 +- .../javafx/javafx3d/LodTransition.java | 42 ++ .../javafx/javafx3d/TileRenderState.java | 76 +++ .../javafx/javafx3d/TiledLodManager.java | 585 ++++++++++++++++-- .../javafx/javafx3d/TiledSurfaceRenderer.java | 272 +++++--- 7 files changed, 1119 insertions(+), 150 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/LodTransition.java create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 9f1345a2..3bd1fd6a 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 @@ -7,7 +7,10 @@ import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; 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 +19,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; @@ -80,6 +84,29 @@ 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 Label lodStatusLabel; + private Label visibleTilesLabel; + private Label lod0CountLabel; + private Label lod1CountLabel; + private Label lod2CountLabel; + private Label triangleCountLabel; + 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 +153,8 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar } e.consume(); }); + + startLodDiagnosticsTimer(); } private TabPane buildTabs() { @@ -351,6 +380,132 @@ 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"); + int finest0 = Math.max(0, Math.min(2, 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 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<>(1, 8, + clampInt(lodConfig.activeBuildsPerPulse, 1, 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"); + triangleCountLabel = new Label("0"); + pendingTransitionsLabel = new Label("0"); + + 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, "Triangles", triangleCountLabel); + addRow(diagnosticsGrid, 6, "Pending", pendingTransitionsLabel); + + 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()); + 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()); + + VBox lodTabContent = new VBox(10, + titledBox("Tile Layout", tileGrid), + titledBox("LOD Selection", selectionGrid), + 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); + // === TabPane === TabPane tabs = new TabPane(); tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); @@ -361,13 +516,91 @@ private TabPane buildTabs() { 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.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())); + triangleCountLabel.setText(formatTriangleCount(stats.visibleTriangles())); + 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/HyperSurfacePlotMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java index 6681fd4b..77bfa7d7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -228,6 +228,41 @@ public final void updateMeshHeightField(HeightField heightField, 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; } 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 eedc4ed0..e5c8976a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -2567,6 +2567,24 @@ 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, 0L, 0); + } + public void setTileCellsL0(int tileCellsL0) { if (tileCellsL0 < 16) { throw new IllegalArgumentException("tileCellsL0 must be >= 16"); @@ -2620,7 +2638,7 @@ private boolean shouldUseTiledHeightFieldRenderer() { && colorationMethod != COLORATION.COLOR_BY_SHAPLEY; } - private boolean isTiledHeightFieldRendererActive() { + public boolean isTiledHeightFieldRendererActive() { return shouldUseTiledHeightFieldRenderer() && surfaceRender && tiledSurfaceRenderer.isVisible(); 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/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 index 15188608..293de383 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.javafx3d; +import javafx.animation.AnimationTimer; import javafx.animation.PauseTransition; import javafx.application.Platform; import javafx.geometry.Point3D; @@ -10,17 +11,21 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.PriorityQueue; +import java.util.Set; import java.util.function.Consumer; /** - * Camera/frustum-aware per-tile LOD selector for a tiled heightfield surface. + * Camera/frustum-aware per-tile LOD selector and transition scheduler. * - *

This class performs no mesh construction. It owns only tile visibility/LOD - * decisions plus the same leading-throttle/trailing-debounce scheduling used by - * the original global {@link LodManager}.

+ *

The manager owns desired LOD state, hysteresis, frustum decisions, the + * prioritized transition queue, and per-pulse transition budgets. It does not + * construct JavaFX meshes; transitions are delegated to the renderer through + * {@link TransitionApplier}.

*/ public final class TiledLodManager { @@ -52,12 +57,34 @@ public TileSpec(int id, int column, int row, } public record TileDecision(int tileId, boolean visible, int lodIndex, - double depth, double pixelsPerCell) { } + 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); + } 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; public long throttleMs = 75; public long debounceMs = 75; public double minDepth = 1e-3; @@ -66,16 +93,33 @@ public static final class Config { /** 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. */ + 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.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; } } @@ -84,9 +128,24 @@ public Config copy() { 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<>(); + /** Stable hysteresis state: where each tile wants to be. */ + private final Map desiredLodByTile = new HashMap<>(); + /** Renderer state: where each tile has actually transitioned to. */ + private final Map appliedLodByTile = new HashMap<>(); + private final Set visibleTileIds = new HashSet<>(); + + private final AnimationTimer transitionTimer = new AnimationTimer() { + @Override + public void handle(long now) { + processTransitionPulse(); + } + }; + private Config config = new Config(); private List levels = List.of(); private List tiles = List.of(); @@ -95,9 +154,12 @@ public Config copy() { private double heightRadius = 0.0; private long lastEvalNanos; private Consumer> onDecisions; - - /** Current per-tile LOD state. Invisible tiles keep their last LOD for hysteresis. */ - private final Map activeLodByTile = new HashMap<>(); + private TransitionApplier transitionApplier; + private boolean transitionTimerRunning; + private boolean settledMode; + private boolean initialEvaluationPending; + private int lastVisibleCount; + private int lastDeferredRefinements; public TiledLodManager(PerspectiveCamera camera, SubScene subScene, Node surfaceNode) { this.camera = Objects.requireNonNull(camera, "camera"); @@ -105,14 +167,40 @@ public TiledLodManager(PerspectiveCamera camera, SubScene subScene, Node surface this.surfaceNode = Objects.requireNonNull(surfaceNode, "surfaceNode"); debounceTimer = new PauseTransition(Duration.millis(config.debounceMs)); - debounceTimer.setOnFinished(e -> evaluateAndNotify(false)); + 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 void setConfig(Config config) { this.config = Objects.requireNonNull(config, "config").copy(); - runOnFx(() -> debounceTimer.setDuration(Duration.millis(this.config.debounceMs))); + validateConfig(this.config); + runOnFx(() -> { + debounceTimer.setDuration(Duration.millis(this.config.debounceMs)); + initialEvaluationTimer.setDuration(Duration.millis(this.config.initialSettleMs)); + System.out.println("Tiled Hypersurface LOD config updated: targetPpc=" + + this.config.targetPixelsPerCell + + ", coarsenBelow=" + this.config.lowThreshold + + ", refineAbove=" + this.config.highThreshold + + ", maxDetail=L" + this.config.finestAllowedLod + + ", 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() { @@ -123,6 +211,10 @@ public void setOnDecisions(Consumer> onDecisions) { this.onDecisions = onDecisions; } + public void setTransitionApplier(TransitionApplier transitionApplier) { + this.transitionApplier = transitionApplier; + } + public void setSurface(List levels, List tiles, double baseWorldWidth, @@ -139,44 +231,141 @@ public void setSurface(List levels, this.baseWorldWidth = baseWorldWidth; this.baseWorldDepth = baseWorldDepth; this.heightRadius = Math.max(0.0, heightRadius); - activeLodByTile.clear(); + desiredLodByTile.clear(); + appliedLodByTile.clear(); + visibleTileIds.clear(); + pendingTransitions.clear(); + stopTransitionTimer(); + lastEvalNanos = 0L; + initialEvaluationPending = true; + runOnFx(() -> { + debounceTimer.stop(); + initialEvaluationTimer.stop(); + initialEvaluationTimer.setDuration(Duration.millis(config.initialSettleMs)); + initialEvaluationTimer.playFromStart(); + System.out.println("Tiled Hypersurface initial LOD deferred: waiting " + + config.initialSettleMs + " ms for stable viewport/camera state"); + }); + } + + public void clearSurface() { + runOnFx(() -> { + levels = List.of(); + tiles = List.of(); + desiredLodByTile.clear(); + appliedLodByTile.clear(); + visibleTileIds.clear(); + pendingTransitions.clear(); + debounceTimer.stop(); + initialEvaluationTimer.stop(); + initialEvaluationPending = false; + stopTransitionTimer(); + }); + } + + /** + * Tells the manager that all renderer-side LOD views were discarded while + * preserving the current surface/pyramid configuration. + */ + public void invalidateRenderedState() { + runOnFx(() -> { + appliedLodByTile.clear(); + pendingTransitions.clear(); + stopTransitionTimer(); + }); } public void requestUpdate(LodManager.UpdateReason reason) { runOnFx(() -> { + if (initialEvaluationPending) { + scheduleInitialEvaluation(); + return; + } + debounceTimer.stop(); debounceTimer.setDuration(Duration.millis(config.debounceMs)); debounceTimer.playFromStart(); + // Camera interaction takes priority over refinement. Stop any + // refinement work released by a previous settled evaluation + // immediately, even if this camera event is inside the throttle window. + settledMode = false; + int cancelledRefinements = cancelQueuedRefinements(); + if (cancelledRefinements > 0) { + System.out.println("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) { - evaluateAndNotify(false); + evaluateAndSchedule(false, false); } }); } - /** Force an immediate evaluation while preserving per-tile hysteresis. */ + /** + * Force a settled evaluation. During initial surface setup this is deliberately + * deferred until the viewport/camera has remained quiet for the configured + * initial-settle interval. + */ public void forceUpdate() { runOnFx(() -> { debounceTimer.stop(); - evaluateAndNotify(true); + 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 evaluateAndNotify(boolean force) { + 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)) { + System.out.println("Tiled Hypersurface initial LOD still deferred: viewport=" + + width + "x" + height); + scheduleInitialEvaluation(); + return; + } + + initialEvaluationPending = false; + System.out.println("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()) return; if (!Double.isFinite(baseWorldWidth) || !Double.isFinite(baseWorldDepth)) return; - final double viewportWidth = Math.max(1.0, subScene.getWidth()); - final double viewportHeight = Math.max(1.0, subScene.getHeight()); + 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); @@ -184,46 +373,258 @@ private void evaluateAndNotify(boolean force) { ArrayList decisions = new ArrayList<>(tiles.size()); Map visibleLods = new HashMap<>(); + Map selectedLods = new HashMap<>(); + int desiredChanges = 0; + int deferredRefinements = 0; + int visibleCount = 0; + visibleTileIds.clear(); for (TileSpec tile : tiles) { CameraTile cameraTile = cameraTile(tile); - boolean visible = isVisible(cameraTile, tile, aspect, tanHalfFov); - int current = activeLodByTile.getOrDefault(tile.id, -1); + boolean visible = isVisible(cameraTile, aspect, tanHalfFov); + int previousDesired = desiredLodByTile.getOrDefault(tile.id, + appliedLodByTile.getOrDefault(tile.id, -1)); if (!visible) { decisions.add(new TileDecision(tile.id, false, - current >= 0 ? current : levels.size() - 1, - cameraTile.depth, 0.0)); + previousDesired >= 0 ? previousDesired : levels.size() - 1, + cameraTile.depth, 0.0, + screenDistance(cameraTile, aspect, tanHalfFov))); continue; } + visibleCount++; + visibleTileIds.add(tile.id); double depth = Math.max(config.minDepth, cameraTile.depth - cameraTile.radius * 0.35); - int selected = chooseLod(current, depth, viewportWidth, viewportHeight, aspect, tanHalfFov); - activeLodByTile.put(tile.id, selected); + int selected = chooseLod(previousDesired, depth, + viewportWidth, viewportHeight, aspect, tanHalfFov); + + if (!settled) { + int applied = appliedLodByTile.getOrDefault(tile.id, -1); + if (applied >= 0 && selected < applied) { + // Refinement is deferred while the camera is active. Keep the + // currently rendered detail and release refinement on settle. + deferredRefinements++; + selected = applied; + } + } + + if (selected != previousDesired) desiredChanges++; + selectedLods.put(tile.id, selected); visibleLods.put(gridKey(tile.column, tile.row), selected); decisions.add(new TileDecision(tile.id, true, selected, depth, - pixelsPerCell(levels.get(selected), depth, viewportWidth, viewportHeight, aspect, tanHalfFov))); + pixelsPerCell(levels.get(selected), depth, + viewportWidth, viewportHeight, aspect, tanHalfFov), + screenDistance(cameraTile, aspect, tanHalfFov))); } - // Prevent abrupt neighboring resolution differences. We only refine the coarser - // neighbor; never coarsen a tile selected by screen-space demand. if (config.maxNeighborLodDelta >= 0) { - enforceNeighborDelta(decisions, visibleLods); + enforceNeighborDelta(selectedLods, visibleLods); + for (int i = 0; i < decisions.size(); i++) { + TileDecision d = decisions.get(i); + if (!d.visible()) continue; + int lod = selectedLods.getOrDefault(d.tileId(), d.lodIndex()); + if (lod != d.lodIndex()) { + decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), + pixelsPerCell(levels.get(lod), d.depth(), + viewportWidth, viewportHeight, aspect, tanHalfFov), + d.screenDistance())); + } + } } - lastEvalNanos = System.nanoTime(); + if (!settled) { + for (int i = 0; i < decisions.size(); i++) { + TileDecision d = decisions.get(i); + if (!d.visible()) continue; + int applied = appliedLodByTile.getOrDefault(d.tileId(), -1); + if (applied >= 0 && d.lodIndex() < applied) { + if (d.lodIndex() != selectedLods.getOrDefault(d.tileId(), d.lodIndex())) { + deferredRefinements++; + } + decisions.set(i, new TileDecision(d.tileId(), true, applied, d.depth(), + pixelsPerCell(levels.get(applied), d.depth(), + viewportWidth, viewportHeight, aspect, tanHalfFov), + d.screenDistance())); + selectedLods.put(d.tileId(), applied); + } + } + } + + desiredChanges = 0; + for (TileDecision decision : decisions) { + if (!decision.visible()) continue; + int previousDesired = desiredLodByTile.getOrDefault(decision.tileId(), + appliedLodByTile.getOrDefault(decision.tileId(), -1)); + if (decision.lodIndex() != previousDesired) desiredChanges++; + desiredLodByTile.put(decision.tileId(), decision.lodIndex()); + } + + lastVisibleCount = visibleCount; + lastDeferredRefinements = deferredRefinements; + settledMode = settled; if (onDecisions != null) onDecisions.accept(List.copyOf(decisions)); + rebuildTransitionQueue(decisions); + lastEvalNanos = System.nanoTime(); + + if (desiredChanges > 0 || !pendingTransitions.isEmpty() || force || deferredRefinements > 0) { + int[] desiredCounts = countDesiredLods(decisions); + 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(", "); + appendLodCounts(sb, desiredCounts); + System.out.println(sb); + if (settled && queuedRefine > 0) { + System.out.println("Tiled Hypersurface settled: refinementReleased=" + + queuedRefine + ", queuedTotal=" + pendingTransitions.size()); + } + } + + if (!pendingTransitions.isEmpty()) startTransitionTimer(); + else stopTransitionTimer(); + } + + 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(List decisions) { + pendingTransitions.clear(); + for (TileDecision decision : decisions) { + if (!decision.visible()) continue; + int from = appliedLodByTile.getOrDefault(decision.tileId(), -1); + int to = decision.lodIndex(); + if (from == to) continue; + + int priorityClass; + if (from >= 0 && to > from) { + // Coarsening reduces render cost, so it is highest priority. + priorityClass = 0; + } else if (from < 0) { + // Newly visible/uninitialized tile. + priorityClass = 1; + } else { + // Refinement can trail slightly behind camera motion. + priorityClass = 2; + } + pendingTransitions.add(new LodTransition( + decision.tileId(), from, to, priorityClass, + decision.screenDistance(), decision.depth(), decision.pixelsPerCell())); + } + } + + 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; + + while (applied < transitionBudget && !pendingTransitions.isEmpty()) { + LodTransition transition = pendingTransitions.poll(); + Integer desired = desiredLodByTile.get(transition.tileId()); + if (desired == null || desired != transition.toLod()) { + continue; // stale request superseded by a newer camera evaluation + } + + int actualFrom = appliedLodByTile.getOrDefault(transition.tileId(), -1); + if (actualFrom == transition.toLod()) continue; + + LodTransition actualTransition = actualFrom == transition.fromLod() + ? transition + : new LodTransition(transition.tileId(), actualFrom, transition.toLod(), + transition.priorityClass(), transition.screenDistance(), + transition.depth(), transition.pixelsPerCell()); + + TransitionResult result = transitionApplier.apply(actualTransition); + if (!result.applied()) continue; + + appliedLodByTile.put(actualTransition.tileId(), 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++; + + // Building TriangleMesh buffers is substantially more expensive than + // toggling cached MeshViews. Keep first-use construction tightly bounded. + if (built >= buildBudget) break; + } + + if (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); + System.out.println(sb); + } + + if (pendingTransitions.isEmpty()) stopTransitionTimer(); + } + + private int currentTransitionBudget() { + return Math.max(1, settledMode + ? config.settledTransitionsPerPulse + : config.activeTransitionsPerPulse); + } + + private int currentBuildBudget() { + return Math.max(1, settledMode + ? config.settledBuildsPerPulse + : config.activeBuildsPerPulse); } private int chooseLod(int current, double depth, double viewportWidth, double viewportHeight, double aspect, double tanHalfFov) { - if (current < 0 || current >= levels.size()) { + 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 = 0; i < levels.size(); i++) { + for (int i = finestAllowed; i < levels.size(); i++) { if (pixelsPerCell(levels.get(i), depth, - viewportWidth, viewportHeight, aspect, tanHalfFov) >= config.targetPixelsPerCell) { + viewportWidth, viewportHeight, aspect, tanHalfFov) + >= config.targetPixelsPerCell) { best = i; break; } @@ -238,13 +639,13 @@ private int chooseLod(int current, double depth, if (ppc >= config.lowThreshold) break; selected++; } - while (selected > 0) { + while (selected > finestAllowed) { double ppc = pixelsPerCell(levels.get(selected), depth, viewportWidth, viewportHeight, aspect, tanHalfFov); if (ppc <= config.highThreshold) break; selected--; } - return selected; + return Math.max(finestAllowed, selected); } private double pixelsPerCell(LodManager.LodLevel level, double depth, @@ -272,7 +673,7 @@ private CameraTile cameraTile(TileSpec tile) { return new CameraTile(cameraPoint.getX(), cameraPoint.getY(), cameraPoint.getZ(), radius); } - private boolean isVisible(CameraTile ct, TileSpec tile, double aspect, double tanHalfFov) { + private boolean isVisible(CameraTile ct, double aspect, double tanHalfFov) { double z = ct.depth; double radius = ct.radius; if (z + radius < camera.getNearClip()) return false; @@ -293,41 +694,50 @@ private boolean isVisible(CameraTile ct, TileSpec tile, double aspect, double ta && Math.abs(ct.y) <= halfHeight + radius; } - private void enforceNeighborDelta(List decisions, Map visibleLods) { + private double screenDistance(CameraTile ct, double aspect, double tanHalfFov) { + double depth = Math.max(config.minDepth, ct.depth); + 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 = ct.x / Math.max(config.minDepth, halfWidth); + double ny = ct.y / Math.max(config.minDepth, halfHeight); + return Math.hypot(nx, ny); + } + + private void enforceNeighborDelta(Map selectedLods, + Map visibleLods) { if (visibleLods.isEmpty()) return; - Map byId = new HashMap<>(); - for (TileDecision d : decisions) byId.put(d.tileId(), d); boolean changed; int guard = Math.max(1, levels.size() * 2); do { changed = false; for (TileSpec tile : tiles) { - TileDecision decision = byId.get(tile.id); - if (decision == null || !decision.visible()) continue; - int lod = activeLodByTile.getOrDefault(tile.id, decision.lodIndex()); + Integer lodObject = selectedLods.get(tile.id); + if (lodObject == null) continue; + int lod = lodObject; int refined = lod; - refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column - 1, tile.row))); - refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column + 1, tile.row))); - refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column, tile.row - 1))); - refined = refineAgainstNeighbor(refined, visibleLods.get(gridKey(tile.column, tile.row + 1))); + refined = refineAgainstNeighbor(refined, + visibleLods.get(gridKey(tile.column - 1, tile.row))); + refined = refineAgainstNeighbor(refined, + visibleLods.get(gridKey(tile.column + 1, tile.row))); + refined = refineAgainstNeighbor(refined, + visibleLods.get(gridKey(tile.column, tile.row - 1))); + refined = refineAgainstNeighbor(refined, + visibleLods.get(gridKey(tile.column, tile.row + 1))); if (refined != lod) { - activeLodByTile.put(tile.id, refined); + selectedLods.put(tile.id, refined); visibleLods.put(gridKey(tile.column, tile.row), refined); changed = true; } } } while (changed && --guard > 0); - - for (int i = 0; i < decisions.size(); i++) { - TileDecision d = decisions.get(i); - if (!d.visible()) continue; - int lod = activeLodByTile.getOrDefault(d.tileId(), d.lodIndex()); - if (lod != d.lodIndex()) { - double ppc = d.pixelsPerCell(); - decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), ppc)); - } - } } private int refineAgainstNeighbor(int lod, Integer neighborLod) { @@ -336,6 +746,71 @@ private int refineAgainstNeighbor(int lod, Integer neighborLod) { return lod > maxAllowed ? maxAllowed : lod; } + + private int[] countAppliedVisibleLods() { + int[] counts = new int[levels.size()]; + for (Integer tileId : visibleTileIds) { + Integer lod = appliedLodByTile.get(tileId); + if (lod != null && lod >= 0 && lod < counts.length) { + counts[lod]++; + } + } + return counts; + } + + private int[] countDesiredLods(List decisions) { + int[] counts = new int[levels.size()]; + for (TileDecision decision : decisions) { + if (decision.visible() + && decision.lodIndex() >= 0 + && decision.lodIndex() < counts.length) { + counts[decision.lodIndex()]++; + } + } + return counts; + } + + 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.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 < 1 + || config.settledBuildsPerPulse < 1) { + throw new IllegalArgumentException("Transition/build budgets must be >= 1"); + } + } + private static long gridKey(int column, int row) { return (((long) column) << 32) ^ (row & 0xffffffffL); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index ea12dd8d..0301a1e8 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -18,12 +18,23 @@ /** * Tiled renderer for primitive HeightField LOD pyramids. * - *

Tiles are defined in the finest rendered level (L0) and keep constant world - * footprints. Each visible tile independently selects a pyramid level through - * {@link TiledLodManager}. Meshes are created lazily and cached while culled.

+ *

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, + long visibleTriangles, + int pendingTransitions + ) { } + public enum ColorMode { IMAGE, HEIGHT @@ -34,7 +45,7 @@ public enum ColorMode { private final PerspectiveCamera camera; private final SubScene subScene; private final TiledLodManager lodManager; - private final Map tilesById = new HashMap<>(); + private final Map tilesById = new HashMap<>(); private List levels = List.of(); private List tileSpecs = List.of(); @@ -54,19 +65,25 @@ public enum ColorMode { 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.setOnDecisions(this::applyDecisions); + this.lodManager.setOnDecisions(this::applyVisibilityDecisions); + this.lodManager.setTransitionApplier(this::applyTransition); setPickOnBounds(false); } public void setTileCellsL0(int tileCellsL0) { - if (tileCellsL0 < 16) throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + if (tileCellsL0 < 16) { + throw new IllegalArgumentException("tileCellsL0 must be >= 16"); + } if (this.tileCellsL0 == tileCellsL0) return; this.tileCellsL0 = tileCellsL0; + System.out.println("Tiled Hypersurface tile size updated: tileCellsL0=" + tileCellsL0); if (!levels.isEmpty()) { rebuildTileLayout(); forceUpdate(); @@ -90,7 +107,9 @@ public void setPyramid(List levels, double baseWorldDepth, double yScale) { Objects.requireNonNull(levels, "levels"); - if (levels.isEmpty()) throw new IllegalArgumentException("levels must not be empty"); + 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"); } @@ -104,10 +123,12 @@ public void setPyramid(List levels, } public void clearSurface() { + lodManager.clearSurface(); tilesById.clear(); tileSpecs = List.of(); levels = List.of(); getChildren().clear(); + visibleTileCount = 0; } public void requestUpdate(LodManager.UpdateReason reason) { @@ -120,39 +141,44 @@ public void forceUpdate() { public void dispose() { lodManager.dispose(); - clearSurface(); + tilesById.clear(); + tileSpecs = List.of(); + levels = List.of(); + getChildren().clear(); } public void setYScale(double yScale) { if (Double.compare(this.yScale, yScale) == 0) return; this.yScale = yScale; - for (SurfaceTile tile : tilesById.values()) tile.currentLod = -1; + + // Geometry Y coordinates changed, so all cached per-LOD MeshViews are invalid. + getChildren().clear(); + for (TileRenderState tile : tilesById.values()) { + tile.clearViews(); + } + lodManager.invalidateRenderedState(); forceUpdate(); } public void setDrawMode(DrawMode drawMode) { this.drawMode = Objects.requireNonNull(drawMode, "drawMode"); - for (SurfaceTile tile : tilesById.values()) { - if (tile.mesh != null) tile.mesh.setDrawMode(drawMode); - } + forEachBuiltView(view -> view.setDrawMode(drawMode)); } public void setCullFace(CullFace cullFace) { this.cullFace = Objects.requireNonNull(cullFace, "cullFace"); - for (SurfaceTile tile : tilesById.values()) { - if (tile.mesh != null) tile.mesh.setCullFace(cullFace); - } + forEachBuiltView(view -> view.setCullFace(cullFace)); } public void setSpecularColor(Color specularColor) { this.specularColor = Objects.requireNonNull(specularColor, "specularColor"); - for (SurfaceTile tile : tilesById.values()) applySpecular(tile); + forEachBuiltView(this::applySpecular); } public void setColorByImage(Image image) { this.image = image; this.colorMode = ColorMode.IMAGE; - for (SurfaceTile tile : tilesById.values()) applyColoration(tile); + forEachBuiltView(this::applyColoration); } public void setColorByHeight(int colors, double min, double max) { @@ -162,7 +188,7 @@ public void setColorByHeight(int colors, double min, double max) { this.colorMin = min; this.colorMax = max; this.colorMode = ColorMode.HEIGHT; - for (SurfaceTile tile : tilesById.values()) applyColoration(tile); + forEachBuiltView(this::applyColoration); } public int getVisibleTileCount() { @@ -177,6 +203,61 @@ 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; + long triangles = 0L; + + for (TileRenderState tile : tilesById.values()) { + if (!tile.isTileVisible()) continue; + visible++; + int lod = tile.getActiveLod(); + if (lod == 0) lod0++; + else if (lod == 1) lod1++; + else if (lod >= 2) lod2++; + if (lod >= 0 && lod < levels.size()) { + triangles += triangleCountFor(tile, lod); + } + } + + return new LodStatistics( + visible, tileSpecs.size(), lod0, lod1, lod2, triangles, + lodManager.getPendingTransitionCount()); + } + + private long triangleCountFor(TileRenderState tile, int lod) { + if (levels.isEmpty() || lod < 0 || lod >= levels.size()) return 0L; + HeightField field = levels.get(lod); + HeightField l0 = levels.get(0); + + int startX = mapBoundary(tile.startX0, l0.width(), field.width()); + int endX = mapBoundary(tile.endX0, l0.width(), field.width()); + int startZ = mapBoundary(tile.startZ0, l0.height(), field.height()); + int endZ = mapBoundary(tile.endZ0, l0.height(), 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)); + + long cellsX = endX - startX; + long cellsZ = endZ - startZ; + return cellsX * cellsZ * 2L; + } + private void rebuildTileLayout() { getChildren().clear(); tilesById.clear(); @@ -204,14 +285,16 @@ private void rebuildTileLayout() { TiledLodManager.TileSpec spec = new TiledLodManager.TileSpec( id, column, row, minX, maxX, minZ, maxZ); specs.add(spec); - tilesById.put(id, new SurfaceTile(spec, startX, endX, startZ, endZ)); + tilesById.put(id, new TileRenderState( + spec, startX, endX, startZ, endZ, levels.size())); id++; } } tileSpecs = List.copyOf(specs); float[] minMax = l0.minMax(); - double maxAbsHeight = Math.max(Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); + double maxAbsHeight = Math.max( + Math.abs(minMax[0] * yScale), Math.abs(minMax[1] * yScale)); lodManager.setSurface( LodManager.fromHeightFields(levels), tileSpecs, @@ -222,48 +305,66 @@ private void rebuildTileLayout() { System.out.println("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + " (" + tileSpecs.size() + "), tileCellsL0=" + tileCellsL0 - + ", world=" + baseWorldWidth + "x" + baseWorldDepth); + + ", world=" + baseWorldWidth + "x" + baseWorldDepth + + ", persistentLodViews=true"); } - private void applyDecisions(List decisions) { + /** + * Visibility/frustum changes are applied immediately. LOD changes are not; + * they are queued and budgeted by TiledLodManager. + */ + private void applyVisibilityDecisions(List decisions) { int visible = 0; - int rebuilt = 0; - int[] lodCounts = new int[levels.size()]; - for (TiledLodManager.TileDecision decision : decisions) { - SurfaceTile tile = tilesById.get(decision.tileId()); + TileRenderState tile = tilesById.get(decision.tileId()); if (tile == null) continue; - if (!decision.visible()) { - if (tile.mesh != null) tile.mesh.setVisible(false); - continue; - } + tile.setTileVisible(decision.visible()); + if (decision.visible()) visible++; - visible++; - int lod = Math.max(0, Math.min(decision.lodIndex(), levels.size() - 1)); - lodCounts[lod]++; - if (tile.mesh == null || tile.currentLod != lod) { - rebuildTile(tile, lod); - rebuilt++; + HyperSurfacePlotMesh activeView = tile.getLodView(tile.getActiveLod()); + if (activeView != null) { + activeView.setVisible(decision.visible() && isVisible()); } - tile.mesh.setVisible(isVisible()); } - visibleTileCount = visible; - lastRebuildCount = rebuilt; - if (rebuilt > 0) { - StringBuilder sb = new StringBuilder("Tiled Hypersurface LOD: visible=") - .append(visible).append('/').append(tileSpecs.size()) - .append(", rebuilt=").append(rebuilt).append(", "); - for (int i = 0; i < lodCounts.length; i++) { - if (i > 0) sb.append(", "); - sb.append("L").append(i).append('=').append(lodCounts[i]); - } - System.out.println(sb); + } + + /** + * 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) { + TileRenderState tile = tilesById.get(transition.tileId()); + 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) { + 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 void rebuildTile(SurfaceTile tile, int lod) { + private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { HeightField field = levels.get(lod); HeightField l0 = levels.get(0); @@ -281,63 +382,52 @@ private void rebuildTile(SurfaceTile tile, int lod) { int cellsZ = endZ - startZ; double scaleX = baseWorldWidth / field.width(); double scaleZ = baseWorldDepth / field.height(); - - if (tile.mesh == null) { - tile.mesh = new HyperSurfacePlotMesh(1, 1, 1, 1, - yScale, 1.0, p -> 0.0); - tile.mesh.setDrawMode(drawMode); - tile.mesh.setCullFace(cullFace); - getChildren().add(tile.mesh); - } - - tile.mesh.updateMeshHeightField(field, startX, startZ, + double translateX = startX * scaleX; + double translateZ = startZ * scaleZ; + + HyperSurfacePlotMesh view = new HyperSurfacePlotMesh( + 1, 1, 1, 1, yScale, 1.0, p -> 0.0); + view.setDrawMode(drawMode); + view.setCullFace(cullFace); + view.updateMeshHeightField(field, startX, startZ, cellsX, cellsZ, scaleX, yScale, scaleZ); - tile.mesh.setTranslateX(startX * scaleX); - tile.mesh.setTranslateZ(startZ * scaleZ); - tile.currentLod = lod; - applyColoration(tile); - applySpecular(tile); + view.setTranslateX(translateX); + view.setTranslateZ(translateZ); + view.setVisible(false); + applyColoration(view); + applySpecular(view); + getChildren().add(view); + return view; } - private void applyColoration(SurfaceTile tile) { - if (tile.mesh == null || !tile.mesh.isDirectHeightFieldMesh()) return; + private void applyColoration(HyperSurfacePlotMesh view) { + if (view == null || !view.isDirectHeightFieldMesh()) return; if (colorMode == ColorMode.IMAGE) { - if (image != null) tile.mesh.setDirectTextureModeImage(image); + if (image != null) view.setDirectTextureModeImage(image); } else { - tile.mesh.setDirectTextureModeByHeight(paletteColors, colorMin, colorMax); + view.setDirectTextureModeByHeight(paletteColors, colorMin, colorMax); } } - private void applySpecular(SurfaceTile tile) { - if (tile.mesh == null) return; - if (tile.mesh.getMaterial() instanceof PhongMaterial material) { + 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.values()) { + 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); } - - private static final class SurfaceTile { - final TiledLodManager.TileSpec spec; - final int startX0; - final int endX0; - final int startZ0; - final int endZ0; - HyperSurfacePlotMesh mesh; - int currentLod = -1; - - SurfaceTile(TiledLodManager.TileSpec spec, - int startX0, int endX0, - int startZ0, int endZ0) { - this.spec = spec; - this.startX0 = startX0; - this.endX0 = endX0; - this.startZ0 = startZ0; - this.endZ0 = endZ0; - } - } } From 8e0992dc6be7807f498b20ffa93b4cb983b2b89e Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 10:41:17 -0400 Subject: [PATCH 06/13] Fixed cullFace and drawMode defaults to be more performant at larger surface sizes. Added L3 and L4 resolution tile layers. --- .../panes/HypersurfaceControlsPane.java | 22 ++++++++----- .../javafx/javafx3d/Hypersurface3DPane.java | 26 +++++++++++++--- .../trinity/javafx/javafx3d/SurfaceUtils.java | 31 +++++++++++++++++++ .../javafx/javafx3d/TiledSurfaceRenderer.java | 12 +++++-- 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 3bd1fd6a..06008025 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 @@ -103,6 +103,8 @@ public class HypersurfaceControlsPane extends LitPathPane { private Label lod0CountLabel; private Label lod1CountLabel; private Label lod2CountLabel; + private Label lod3CountLabel; + private Label lod4CountLabel; private Label triangleCountLabel; private Label pendingTransitionsLabel; private AnimationTimer lodDiagnosticsTimer; @@ -206,13 +208,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); @@ -396,8 +398,8 @@ private TabPane buildTabs() { styleCombo(tileSizeCombo); maxDetailCombo = new ComboBox<>(); - maxDetailCombo.getItems().addAll("L0", "L1", "L2"); - int finest0 = Math.max(0, Math.min(2, lodConfig.finestAllowedLod)); + maxDetailCombo.getItems().addAll("L0", "L1", "L2", "L3", "L4"); + int finest0 = Math.max(0, Math.min(4, lodConfig.finestAllowedLod)); maxDetailCombo.getSelectionModel().select(finest0); styleCombo(maxDetailCombo); @@ -466,6 +468,8 @@ private TabPane buildTabs() { lod0CountLabel = new Label("0"); lod1CountLabel = new Label("0"); lod2CountLabel = new Label("0"); + lod3CountLabel = new Label("0"); + lod4CountLabel = new Label("0"); triangleCountLabel = new Label("0"); pendingTransitionsLabel = new Label("0"); @@ -473,9 +477,11 @@ private TabPane buildTabs() { 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, "Triangles", triangleCountLabel); - addRow(diagnosticsGrid, 6, "Pending", pendingTransitionsLabel); + addRow(diagnosticsGrid, 4, "L2 tiles", lod2CountLabel); + addRow(diagnosticsGrid, 5, "L3 tiles", lod3CountLabel); + addRow(diagnosticsGrid, 6, "L4 tiles", lod4CountLabel); + addRow(diagnosticsGrid, 7, "Triangles", triangleCountLabel); + addRow(diagnosticsGrid, 8, "Pending", pendingTransitionsLabel); tileSizeCombo.setOnAction(e -> { if (target != null && tileSizeCombo.getValue() != null) { @@ -579,6 +585,8 @@ private void refreshLodDiagnostics() { 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())); pendingTransitionsLabel.setText(Integer.toString(stats.pendingTransitions())); } 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 e5c8976a..14cb17db 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -273,9 +273,12 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} /** Max render dimension for L0 (aspect-preserving). Default safe limit: 2048. */ private int maxRenderResolution = 2048; - /** Minimum LOD dimension (stop generating levels below this). Default: 512. */ + /** 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; + /** Full-resolution height data (primitive). Derived from originalGrid or other data sources. */ private HeightField fullResHeightField = null; @@ -1583,9 +1586,9 @@ private void loadSurf3D() { captureDataGridAsSource(); surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, 1, 1, yScale, surfScale, vert3DLookup); 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); @@ -2582,7 +2585,7 @@ public void setTiledLodConfig(TiledLodManager.Config config) { public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { return tiledSurfaceRenderer != null ? tiledSurfaceRenderer.getLodStatistics() - : new TiledSurfaceRenderer.LodStatistics(0, 0, 0, 0, 0, 0L, 0); + : new TiledSurfaceRenderer.LodStatistics(0, 0, 0, 0, 0, 0, 0, 0L, 0); } public void setTileCellsL0(int tileCellsL0) { @@ -2735,7 +2738,20 @@ private void rebuildProcessedGridAndRefresh() { toneParam ); - lodProcessedLevels = SurfaceUtils.buildPyramid(l0Processed, minRenderResolution); + 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()); + } + System.out.println(lodSummary); + } // Rendering resolution and physical world size are intentionally independent. // Image-backed sources use a nominal long-axis world extent with aspect ratio 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 2a095c21..eb1b1023 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -684,6 +684,37 @@ public static List buildPyramid(HeightField l0Processed, int minDim 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) // ------------------------- diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 0301a1e8..75c12cd7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -31,6 +31,8 @@ public record LodStatistics( int lod0Tiles, int lod1Tiles, int lod2Tiles, + int lod3Tiles, + int lod4Tiles, long visibleTriangles, int pendingTransitions ) { } @@ -55,7 +57,7 @@ public enum ColorMode { private int tileCellsL0 = DEFAULT_TILE_CELLS_L0; private DrawMode drawMode = DrawMode.FILL; - private CullFace cullFace = CullFace.NONE; + private CullFace cullFace = CullFace.BACK; private ColorMode colorMode = ColorMode.HEIGHT; private Image image; private int paletteColors = 1530; @@ -220,6 +222,8 @@ public LodStatistics getLodStatistics() { int lod0 = 0; int lod1 = 0; int lod2 = 0; + int lod3 = 0; + int lod4 = 0; long triangles = 0L; for (TileRenderState tile : tilesById.values()) { @@ -228,14 +232,16 @@ public LodStatistics getLodStatistics() { int lod = tile.getActiveLod(); if (lod == 0) lod0++; else if (lod == 1) lod1++; - else if (lod >= 2) lod2++; + else if (lod == 2) lod2++; + else if (lod == 3) lod3++; + else if (lod >= 4) lod4++; if (lod >= 0 && lod < levels.size()) { triangles += triangleCountFor(tile, lod); } } return new LodStatistics( - visible, tileSpecs.size(), lod0, lod1, lod2, triangles, + visible, tileSpecs.size(), lod0, lod1, lod2, lod3, lod4, triangles, lodManager.getPendingTransitionCount()); } From 5b1c007ff954a662f0de9d77d7155b9f35dc2567 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 12:31:22 -0400 Subject: [PATCH 07/13] Triangle Budget allows smoother camera transitions. --- .../panes/HypersurfaceControlsPane.java | 50 +++- .../javafx/javafx3d/Hypersurface3DPane.java | 3 +- .../javafx/javafx3d/TiledLodManager.java | 227 ++++++++++++++++-- .../javafx/javafx3d/TiledSurfaceRenderer.java | 12 + 4 files changed, 270 insertions(+), 22 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 06008025..7e8c8fcc 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 @@ -42,6 +42,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; @@ -97,6 +98,8 @@ public class HypersurfaceControlsPane extends LitPathPane { private Spinner settledTransitionsSpinner; private Spinner activeBuildsSpinner; private Spinner settledBuildsSpinner; + private CheckBox geometryBudgetCheck; + private Spinner triangleBudgetSpinner; private Label lodStatusLabel; private Label visibleTilesLabel; @@ -106,6 +109,9 @@ public class HypersurfaceControlsPane extends LitPathPane { private Label lod3CountLabel; private Label lod4CountLabel; private Label triangleCountLabel; + private Label rawRequestedTrianglesLabel; + private Label budgetTargetTrianglesLabel; + private Label budgetCoarsenedLabel; private Label pendingTransitionsLabel; private AnimationTimer lodDiagnosticsTimer; @@ -424,6 +430,16 @@ private TabPane buildTabs() { 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); @@ -471,6 +487,9 @@ private TabPane buildTabs() { 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"); addRow(diagnosticsGrid, 0, "Renderer", lodStatusLabel); @@ -480,8 +499,11 @@ private TabPane buildTabs() { addRow(diagnosticsGrid, 4, "L2 tiles", lod2CountLabel); addRow(diagnosticsGrid, 5, "L3 tiles", lod3CountLabel); addRow(diagnosticsGrid, 6, "L4 tiles", lod4CountLabel); - addRow(diagnosticsGrid, 7, "Triangles", triangleCountLabel); - addRow(diagnosticsGrid, 8, "Pending", pendingTransitionsLabel); + 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); tileSizeCombo.setOnAction(e -> { if (target != null && tileSizeCombo.getValue() != null) { @@ -492,6 +514,8 @@ private TabPane buildTabs() { 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()); @@ -503,6 +527,7 @@ private TabPane buildTabs() { VBox lodTabContent = new VBox(10, titledBox("Tile Layout", tileGrid), titledBox("LOD Selection", selectionGrid), + titledBox("Geometry Budget", budgetGrid), titledBox("Scheduling", timingGrid), titledBox("Live Diagnostics", diagnosticsGrid) ); @@ -511,11 +536,25 @@ private TabPane buildTabs() { 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); @@ -551,6 +590,10 @@ private void applyLodConfigFromControls() { 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.throttleMs = throttleMsSpinner.getValue(); config.debounceMs = settleMsSpinner.getValue(); config.initialSettleMs = initialSettleMsSpinner.getValue(); @@ -588,6 +631,9 @@ private void refreshLodDiagnostics() { 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())); } 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 14cb17db..ac7c5f3f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -2585,7 +2585,8 @@ public void setTiledLodConfig(TiledLodManager.Config config) { public TiledSurfaceRenderer.LodStatistics getTiledLodStatistics() { return tiledSurfaceRenderer != null ? tiledSurfaceRenderer.getLodStatistics() - : new TiledSurfaceRenderer.LodStatistics(0, 0, 0, 0, 0, 0, 0, 0L, 0); + : new TiledSurfaceRenderer.LodStatistics( + 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0L, 0, 0); } public void setTileCellsL0(int tileCellsL0) { diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java index 293de383..990d1389 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -79,12 +79,21 @@ public interface TransitionApplier { TransitionResult apply(LodTransition transition); } + @FunctionalInterface + public interface GeometryCostProvider { + long trianglesFor(int tileId, int lodIndex); + } + 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; public long throttleMs = 75; public long debounceMs = 75; public double minDepth = 1e-3; @@ -110,6 +119,8 @@ public Config copy() { c.lowThreshold = lowThreshold; c.highThreshold = highThreshold; c.finestAllowedLod = finestAllowedLod; + c.geometryBudgetEnabled = geometryBudgetEnabled; + c.triangleBudget = triangleBudget; c.throttleMs = throttleMs; c.debounceMs = debounceMs; c.minDepth = minDepth; @@ -133,7 +144,9 @@ public Config copy() { (obs, oldValue, newValue) -> requestUpdate(LodManager.UpdateReason.RESIZE); private final PriorityQueue pendingTransitions = new PriorityQueue<>(); - /** Stable hysteresis state: where each tile wants to be. */ + /** Stable screen-space/hysteresis state before aggregate budget constraints. */ + private final Map qualityLodByTile = new HashMap<>(); + /** Final target state after budget and interaction policy. */ private final Map desiredLodByTile = new HashMap<>(); /** Renderer state: where each tile has actually transitioned to. */ private final Map appliedLodByTile = new HashMap<>(); @@ -155,11 +168,15 @@ public void handle(long now) { private long lastEvalNanos; private Consumer> onDecisions; 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"); @@ -185,6 +202,8 @@ public void setConfig(Config config) { + ", 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 @@ -215,6 +234,22 @@ 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, @@ -231,10 +266,14 @@ public void setSurface(List levels, this.baseWorldWidth = baseWorldWidth; this.baseWorldDepth = baseWorldDepth; this.heightRadius = Math.max(0.0, heightRadius); + qualityLodByTile.clear(); desiredLodByTile.clear(); appliedLodByTile.clear(); visibleTileIds.clear(); pendingTransitions.clear(); + lastRawRequestedTriangles = 0L; + lastBudgetedTargetTriangles = 0L; + lastBudgetCoarsenedTiles = 0; stopTransitionTimer(); lastEvalNanos = 0L; initialEvaluationPending = true; @@ -252,10 +291,14 @@ public void clearSurface() { runOnFx(() -> { levels = List.of(); tiles = List.of(); + qualityLodByTile.clear(); desiredLodByTile.clear(); appliedLodByTile.clear(); visibleTileIds.clear(); pendingTransitions.clear(); + lastRawRequestedTriangles = 0L; + lastBudgetedTargetTriangles = 0L; + lastBudgetCoarsenedTiles = 0; debounceTimer.stop(); initialEvaluationTimer.stop(); initialEvaluationPending = false; @@ -382,12 +425,12 @@ private void evaluateAndSchedule(boolean force, boolean settled) { for (TileSpec tile : tiles) { CameraTile cameraTile = cameraTile(tile); boolean visible = isVisible(cameraTile, aspect, tanHalfFov); - int previousDesired = desiredLodByTile.getOrDefault(tile.id, + int previousQuality = qualityLodByTile.getOrDefault(tile.id, appliedLodByTile.getOrDefault(tile.id, -1)); if (!visible) { decisions.add(new TileDecision(tile.id, false, - previousDesired >= 0 ? previousDesired : levels.size() - 1, + previousQuality >= 0 ? previousQuality : levels.size() - 1, cameraTile.depth, 0.0, screenDistance(cameraTile, aspect, tanHalfFov))); continue; @@ -397,20 +440,8 @@ private void evaluateAndSchedule(boolean force, boolean settled) { visibleTileIds.add(tile.id); double depth = Math.max(config.minDepth, cameraTile.depth - cameraTile.radius * 0.35); - int selected = chooseLod(previousDesired, depth, + int selected = chooseLod(previousQuality, depth, viewportWidth, viewportHeight, aspect, tanHalfFov); - - if (!settled) { - int applied = appliedLodByTile.getOrDefault(tile.id, -1); - if (applied >= 0 && selected < applied) { - // Refinement is deferred while the camera is active. Keep the - // currently rendered detail and release refinement on settle. - deferredRefinements++; - selected = applied; - } - } - - if (selected != previousDesired) desiredChanges++; selectedLods.put(tile.id, selected); visibleLods.put(gridKey(tile.column, tile.row), selected); decisions.add(new TileDecision(tile.id, true, selected, depth, @@ -434,15 +465,25 @@ private void evaluateAndSchedule(boolean force, boolean settled) { } } + for (TileDecision decision : decisions) { + if (decision.visible()) { + qualityLodByTile.put(decision.tileId(), decision.lodIndex()); + } + } + + BudgetResult budgetResult = applyGeometryBudget( + decisions, selectedLods, visibleLods, viewportWidth, viewportHeight, aspect, tanHalfFov); + lastRawRequestedTriangles = budgetResult.rawTriangles(); + lastBudgetedTargetTriangles = budgetResult.budgetedTriangles(); + lastBudgetCoarsenedTiles = budgetResult.coarsenedTiles(); + if (!settled) { for (int i = 0; i < decisions.size(); i++) { TileDecision d = decisions.get(i); if (!d.visible()) continue; int applied = appliedLodByTile.getOrDefault(d.tileId(), -1); if (applied >= 0 && d.lodIndex() < applied) { - if (d.lodIndex() != selectedLods.getOrDefault(d.tileId(), d.lodIndex())) { - deferredRefinements++; - } + deferredRefinements++; decisions.set(i, new TileDecision(d.tileId(), true, applied, d.depth(), pixelsPerCell(levels.get(applied), d.depth(), viewportWidth, viewportHeight, aspect, tanHalfFov), @@ -489,6 +530,11 @@ private void evaluateAndSchedule(boolean force, boolean settled) { .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); System.out.println(sb); @@ -502,6 +548,120 @@ private void evaluateAndSchedule(boolean force, boolean settled) { else stopTransitionTimer(); } + private BudgetResult applyGeometryBudget( + List decisions, + Map selectedLods, + Map visibleLods, + double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + + long rawTriangles = totalTriangles(decisions, selectedLods); + if (!config.geometryBudgetEnabled + || config.triangleBudget <= 0L + || geometryCostProvider == null + || rawTriangles <= config.triangleBudget) { + return new BudgetResult(rawTriangles, rawTriangles, 0); + } + + Map decisionByTile = new HashMap<>(); + for (TileDecision decision : decisions) { + if (decision.visible()) decisionByTile.put(decision.tileId(), decision); + } + + long triangles = rawTriangles; + Set coarsenedTiles = new HashSet<>(); + int guard = Math.max(1, decisionByTile.size() * Math.max(1, levels.size()) * 2); + + while (triangles > config.triangleBudget && guard-- > 0) { + BudgetCandidate candidate = null; + + for (TileSpec tile : tiles) { + Integer currentObject = selectedLods.get(tile.id); + if (currentObject == null) continue; + int current = currentObject; + if (current < 0 || current >= levels.size() - 1) continue; + + int proposed = current + 1; + if (!budgetNeighborCompatible(tile, proposed, visibleLods)) continue; + + long currentCost = triangleCost(tile.id, current); + long proposedCost = triangleCost(tile.id, proposed); + long savings = currentCost - proposedCost; + if (savings <= 0L) continue; + + TileDecision decision = decisionByTile.get(tile.id); + if (decision == null) continue; + BudgetCandidate next = new BudgetCandidate(tile, decision, current, proposed, savings); + if (candidate == null || next.isLowerPriorityThan(candidate)) { + candidate = next; + } + } + + if (candidate == null) break; + + selectedLods.put(candidate.tile().id, candidate.toLod()); + visibleLods.put(gridKey(candidate.tile().column, candidate.tile().row), candidate.toLod()); + coarsenedTiles.add(candidate.tile().id); + triangles -= candidate.savings(); + } + + for (int i = 0; i < decisions.size(); i++) { + TileDecision d = decisions.get(i); + if (!d.visible()) continue; + int lod = selectedLods.getOrDefault(d.tileId(), d.lodIndex()); + if (lod != d.lodIndex()) { + decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), + pixelsPerCell(levels.get(lod), d.depth(), + viewportWidth, viewportHeight, aspect, tanHalfFov), + d.screenDistance())); + } + } + + long exactBudgetedTriangles = totalTriangles(decisions, selectedLods); + if (exactBudgetedTriangles > config.triangleBudget) { + System.out.println("Tiled Hypersurface geometry budget warning: requested=" + + formatTriangleCount(rawTriangles) + + ", budget=" + formatTriangleCount(config.triangleBudget) + + ", achievable=" + formatTriangleCount(exactBudgetedTriangles) + + ", neighborDelta=" + config.maxNeighborLodDelta); + } + return new BudgetResult(rawTriangles, exactBudgetedTriangles, coarsenedTiles.size()); + } + + private long totalTriangles(List decisions, Map lods) { + if (geometryCostProvider == null) return 0L; + long total = 0L; + for (TileDecision decision : decisions) { + if (!decision.visible()) continue; + int lod = lods.getOrDefault(decision.tileId(), decision.lodIndex()); + total += triangleCost(decision.tileId(), 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, + Map visibleLods) { + if (config.maxNeighborLodDelta < 0) return true; + return budgetNeighborCompatible(proposedLod, + visibleLods.get(gridKey(tile.column - 1, tile.row))) + && budgetNeighborCompatible(proposedLod, + visibleLods.get(gridKey(tile.column + 1, tile.row))) + && budgetNeighborCompatible(proposedLod, + visibleLods.get(gridKey(tile.column, tile.row - 1))) + && budgetNeighborCompatible(proposedLod, + visibleLods.get(gridKey(tile.column, tile.row + 1))); + } + + private boolean budgetNeighborCompatible(int proposedLod, Integer neighborLod) { + return neighborLod == null + || proposedLod <= neighborLod + config.maxNeighborLodDelta; + } + private int cancelQueuedRefinements() { int before = pendingTransitions.size(); pendingTransitions.removeIf(LodTransition::isRefining); @@ -800,6 +960,9 @@ private static void validateConfig(Config config) { 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"); } @@ -820,5 +983,31 @@ private static void runOnFx(Runnable runnable) { 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); + } + + private record BudgetResult(long rawTriangles, long budgetedTriangles, int coarsenedTiles) { } + + private record BudgetCandidate(TileSpec tile, TileDecision decision, + int fromLod, int toLod, long savings) { + boolean isLowerPriorityThan(BudgetCandidate other) { + // Larger normalized screen distance is less visually important. + int screenCmp = Double.compare(decision.screenDistance(), other.decision.screenDistance()); + if (screenCmp != 0) return screenCmp > 0; + // At comparable screen position, farther tiles lose detail first. + int depthCmp = Double.compare(decision.depth(), other.decision.depth()); + if (depthCmp != 0) return depthCmp > 0; + // Prefer the demotion that buys the most triangles when visual priority ties. + return savings > other.savings; + } + } + private record CameraTile(double x, double y, double depth, double radius) { } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 75c12cd7..106a438b 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -34,6 +34,9 @@ public record LodStatistics( int lod3Tiles, int lod4Tiles, long visibleTriangles, + long rawRequestedTriangles, + long budgetedTargetTriangles, + int budgetCoarsenedTiles, int pendingTransitions ) { } @@ -76,6 +79,7 @@ public TiledSurfaceRenderer(PerspectiveCamera camera, SubScene subScene) { this.lodManager = new TiledLodManager(camera, subScene, this); this.lodManager.setOnDecisions(this::applyVisibilityDecisions); this.lodManager.setTransitionApplier(this::applyTransition); + this.lodManager.setGeometryCostProvider(this::triangleCountForTileId); setPickOnBounds(false); } @@ -242,9 +246,17 @@ public LodStatistics getLodStatistics() { 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) { + TileRenderState tile = tilesById.get(tileId); + return tile != null ? triangleCountFor(tile, lod) : 0L; + } + private long triangleCountFor(TileRenderState tile, int lod) { if (levels.isEmpty() || lod < 0 || lod >= levels.size()) return 0L; HeightField field = levels.get(lod); From 3e1e43936f01a3fb4f144b7bb066ba7b07dd6585 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 13:05:23 -0400 Subject: [PATCH 08/13] Fixed default mesh winding to face front towards Y-. --- .../panes/HypersurfaceControlsPane.java | 4 +- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 34 +++++++------- .../javafx/javafx3d/TiledLodManager.java | 46 +++++++++++++++---- .../javafx/javafx3d/TiledSurfaceRenderer.java | 6 ++- 4 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 7e8c8fcc..bb9490a0 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 @@ -449,8 +449,8 @@ private TabPane buildTabs() { clampInt(lodConfig.activeTransitionsPerPulse, 1, 32), 1); settledTransitionsSpinner = new Spinner<>(1, 64, clampInt(lodConfig.settledTransitionsPerPulse, 1, 64), 1); - activeBuildsSpinner = new Spinner<>(1, 8, - clampInt(lodConfig.activeBuildsPerPulse, 1, 8), 1); + activeBuildsSpinner = new Spinner<>(0, 8, + clampInt(lodConfig.activeBuildsPerPulse, 0, 8), 1); settledBuildsSpinner = new Spinner<>(1, 16, clampInt(lodConfig.settledBuildsPerPulse, 1, 16), 1); 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 77bfa7d7..f55faa9d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -606,19 +606,21 @@ private TriangleMesh createHeightFieldMesh(HeightField heightField, final int p10 = p00 + vertsX; final int p11 = p10 + 1; + // Winding is intentionally reversed so the surface front faces Y-. + // This makes CullFace.BACK preserve the normal top-down view. faces[fi++] = p00; faces[fi++] = p00; - faces[fi++] = p10; - faces[fi++] = p10; faces[fi++] = p11; faces[fi++] = p11; + faces[fi++] = p10; + faces[fi++] = p10; faces[fi++] = p11; faces[fi++] = p11; - faces[fi++] = p01; - faces[fi++] = p01; faces[fi++] = p00; faces[fi++] = p00; + faces[fi++] = p01; + faces[fi++] = p01; } } @@ -749,10 +751,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)); } } @@ -832,10 +834,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 @@ -876,10 +878,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/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java index 990d1389..6ccdd81d 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -76,7 +76,7 @@ public static TransitionResult notApplied() { @FunctionalInterface public interface TransitionApplier { - TransitionResult apply(LodTransition transition); + TransitionResult apply(LodTransition transition, boolean allowBuild); } @FunctionalInterface @@ -108,7 +108,7 @@ public static final class Config { 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. */ + /** 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; @@ -710,8 +710,19 @@ private void processTransitionPulse() { int refined = 0; int initial = 0; - while (applied < transitionBudget && !pendingTransitions.isEmpty()) { + // When activeBuildsPerPulse is zero, uncached transitions are deferred rather + // than forcing a TriangleMesh construction during camera interaction. We still + // scan the current queue once so cached LOD switches can proceed normally. + final int transitionsAvailableAtStart = pendingTransitions.size(); + final List buildDeferred = new ArrayList<>(); + int examined = 0; + + while (applied < transitionBudget + && !pendingTransitions.isEmpty() + && examined < transitionsAvailableAtStart) { + LodTransition transition = pendingTransitions.poll(); + examined++; Integer desired = desiredLodByTile.get(transition.tileId()); if (desired == null || desired != transition.toLod()) { continue; // stale request superseded by a newer camera evaluation @@ -726,8 +737,14 @@ private void processTransitionPulse() { transition.priorityClass(), transition.screenDistance(), transition.depth(), transition.pixelsPerCell()); - TransitionResult result = transitionApplier.apply(actualTransition); - if (!result.applied()) continue; + boolean allowBuild = built < buildBudget; + TransitionResult result = transitionApplier.apply(actualTransition, allowBuild); + if (!result.applied()) { + // With a zero/exhausted build budget the renderer reports an uncached + // transition as not applied. Preserve it for a later settled pulse. + if (!allowBuild) buildDeferred.add(actualTransition); + continue; + } appliedLodByTile.put(actualTransition.tileId(), actualTransition.toLod()); applied++; @@ -739,9 +756,11 @@ private void processTransitionPulse() { // Building TriangleMesh buffers is substantially more expensive than // toggling cached MeshViews. Keep first-use construction tightly bounded. - if (built >= buildBudget) break; + if (buildBudget > 0 && built >= buildBudget) break; } + pendingTransitions.addAll(buildDeferred); + if (applied > 0) { int[] actualCounts = countAppliedVisibleLods(); StringBuilder sb = new StringBuilder("Tiled Hypersurface transition pulse: visible=") @@ -760,7 +779,13 @@ private void processTransitionPulse() { System.out.println(sb); } - if (pendingTransitions.isEmpty()) stopTransitionTimer(); + if (pendingTransitions.isEmpty() + || (!settledMode && buildBudget == 0 && applied == 0)) { + // Avoid polling the same uncached transitions every JavaFX pulse when + // active mesh construction is explicitly disabled. A later camera + // evaluation or the settled evaluation will restart the timer. + stopTransitionTimer(); + } } private int currentTransitionBudget() { @@ -770,7 +795,7 @@ private int currentTransitionBudget() { } private int currentBuildBudget() { - return Math.max(1, settledMode + return Math.max(0, settledMode ? config.settledBuildsPerPulse : config.activeBuildsPerPulse); } @@ -968,9 +993,10 @@ private static void validateConfig(Config config) { } if (config.activeTransitionsPerPulse < 1 || config.settledTransitionsPerPulse < 1 - || config.activeBuildsPerPulse < 1 + || config.activeBuildsPerPulse < 0 || config.settledBuildsPerPulse < 1) { - throw new IllegalArgumentException("Transition/build budgets must be >= 1"); + throw new IllegalArgumentException( + "Transition budgets and settled build budget must be >= 1; active build budget must be >= 0"); } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 106a438b..455ab58a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -352,7 +352,8 @@ private void applyVisibilityDecisions(List decisio * 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) { + private TiledLodManager.TransitionResult applyTransition( + LodTransition transition, boolean allowBuild) { TileRenderState tile = tilesById.get(transition.tileId()); if (tile == null) return TiledLodManager.TransitionResult.notApplied(); @@ -361,6 +362,9 @@ private TiledLodManager.TransitionResult applyTransition(LodTransition transitio boolean built = false; if (targetView == null) { + if (!allowBuild) { + return TiledLodManager.TransitionResult.notApplied(); + } targetView = buildTileLodView(tile, lod); tile.setLodView(lod, targetView); built = true; From b363f674b23c2a189eee6fbe68250d9fb2558cdd Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 13:40:45 -0400 Subject: [PATCH 09/13] Z ordering of pixels/rows mapped into the surface plot can now be reversed. --- .../panes/HypersurfaceControlsPane.java | 22 +++++ .../javafx/javafx3d/HyperSurfacePlotMesh.java | 39 +++++++- .../javafx/javafx3d/Hypersurface3DPane.java | 90 +++++++++++++++++-- .../javafx3d/SurfaceRowOrientation.java | 30 +++++++ .../javafx/javafx3d/TiledSurfaceRenderer.java | 22 +++++ 5 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index bb9490a0..9571f470 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -6,6 +6,7 @@ import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.SurfaceRowOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; import edu.jhuapl.trinity.javafx.javafx3d.TiledLodManager; import edu.jhuapl.trinity.javafx.javafx3d.TiledSurfaceRenderer; @@ -62,6 +63,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private ComboBox drawModeCombo; private ComboBox cullFaceCombo; private ComboBox colorationCombo; + private ComboBox rowOrientationCombo; // Processing private ComboBox heightModeCombo; @@ -234,6 +236,14 @@ 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); + meshTypeCombo.setOnAction(e -> fireOnRoot(HypersurfaceEvent.surfaceRender("Surface".equals(meshTypeCombo.getValue())))); drawModeCombo.setOnAction(e -> @@ -242,6 +252,18 @@ 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()); + } + }); + if (target != null) { + target.surfaceRowOrientationProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null && rowOrientationCombo.getValue() != newValue) { + rowOrientationCombo.getSelectionModel().select(newValue); + } + }); + } // === Scene / Lighting === GridPane sceneGrid = formGrid(); 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 f55faa9d..4f6cde0a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -88,6 +88,7 @@ public class HyperSurfacePlotMesh extends TexturedMesh { private double directZScale; private WritableImage directPaletteImage; private int directPaletteColors = -1; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; public HyperSurfacePlotMesh() { this(DEFAULT_FUNCTION, DEFAULT_X_RANGE, DEFAULT_Y_RANGE, DEFAULT_X_DIVISIONS, DEFAULT_Y_DIVISIONS, DEFAULT_FUNCTION_SCALE); @@ -137,6 +138,20 @@ 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 javafx.geometry.Point3D getPoint3DByVertNumber(int pointId) { if (directHeightFieldMesh && mesh != null) { int base = Math.multiplyExact(pointId, 3); @@ -297,7 +312,7 @@ public void setDirectTextureModeByHeight(int colors, double min, double max) { final float[] texRow = new float[directVertsX * 2]; for (int localZ = 0; localZ < directVertsZ; localZ++) { - int sampleZ = Math.min(directStartZ + localZ, sourceHeight - 1); + int sampleZ = mapSourceRow(directStartZ + localZ, sourceHeight); int sourceRow = sampleZ * sourceWidth; int out = 0; for (int localX = 0; localX < directVertsX; localX++) { @@ -558,10 +573,10 @@ private TriangleMesh createHeightFieldMesh(HeightField heightField, for (int localZ = 0; localZ < vertsZ; localZ++) { final int globalVertexZ = startZ + localZ; - final int sampleZ = Math.min(globalVertexZ, sourceHeight - 1); + final int sampleZ = mapSourceRow(globalVertexZ, sourceHeight); final int sourceRow = sampleZ * sourceWidth; final float worldZ = (float) (localZ * zScale); - final float v = (float) globalVertexZ / (float) sourceHeight; + final float v = spatialV(globalVertexZ, sourceHeight); int p = 0; int t = 0; @@ -642,7 +657,7 @@ private void restoreDirectSpatialTexCoords() { for (int localZ = 0; localZ < directVertsZ; localZ++) { final int globalVertexZ = directStartZ + localZ; - final float v = (float) globalVertexZ / (float) sourceHeight; + final float v = spatialV(globalVertexZ, sourceHeight); int t = 0; for (int localX = 0; localX < directVertsX; localX++) { final int globalVertexX = directStartX + localX; @@ -654,6 +669,22 @@ private void restoreDirectSpatialTexCoords() { } } + 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; 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 ac7c5f3f..a644033a 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,9 @@ 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 boolean suppressRowOrientationRefresh = false; boolean hoverInteractionsEnabled = false; boolean surfaceChartsEnabled = false; boolean crosshairsEnabled = false; @@ -339,6 +344,12 @@ 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(); + } + }); shape3DToCalloutMap = new HashMap<>(); ambientLight = new AmbientLight(Color.WHITE); @@ -1104,6 +1115,7 @@ public void updateTheMesh() { final double renderScaleZ = getRenderScaleZ(); if (surfaceRender) { + surfPlot.setRowOrientation(getSurfaceRowOrientation()); // 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. @@ -1397,7 +1409,9 @@ private void handleSurfaceHover(Point3D surfacePoint) { int column = Math.max(0, Math.min( (int) Math.floor(vertP3D.getX() / Math.max(1e-9, getRenderScaleX())), getRenderWidth() - 1)); - int sourceRow = mapRenderIndexToSource(row, getRenderHeight(), getSourceHeight()); + int orientedRenderRow = orientRowIndex(row, getRenderHeight()); + int sourceRow = mapRenderIndexToSource( + orientedRenderRow, getRenderHeight(), getSourceHeight()); if (anchorCallout != null) { if (sourceRow < featureVectors.size()) { @@ -1497,6 +1511,7 @@ private Number vertToHeight(Vert3D p) { 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: @@ -1512,6 +1527,7 @@ private Number vertToHeight(Vert3D p) { 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: @@ -1525,11 +1541,14 @@ private Number lookupPoint(Vert3D p) { int w = activeHeightField.width(); int h = activeHeightField.height(); if (p.yIndex < 0 || p.yIndex >= h || p.xIndex < 0 || p.xIndex >= w) return 0.0; - return (double) activeHeightField.data()[p.yIndex * w + p.xIndex]; + int dataRow = orientRowIndex(p.yIndex, h); + return (double) activeHeightField.data()[dataRow * w + p.xIndex]; } if (dataGrid == null) return 0.0; - if (p.yIndex >= dataGrid.size() || p.xIndex >= dataGrid.get(0).size()) return 0.0; - return dataGrid.get(p.yIndex).get(p.xIndex); + 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) { @@ -1585,6 +1604,7 @@ private void loadSurf3D() { generateRandos(xWidth, zWidth, yScale); captureDataGridAsSource(); surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, 1, 1, yScale, surfScale, vert3DLookup); + surfPlot.setRowOrientation(getSurfaceRowOrientation()); surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); surfPlot.setDrawMode(DrawMode.FILL); sceneRoot.getChildren().add(surfPlot); @@ -1610,6 +1630,7 @@ private void loadSurf3D() { if (tiledSurfaceRenderer == null) { tiledSurfaceRenderer = new TiledSurfaceRenderer(camera, subScene); + tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); tiledSurfaceRenderer.setVisible(false); sceneRoot.getChildren().add(tiledSurfaceRenderer); @@ -2292,6 +2313,7 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { baseWorldDepth = Double.NaN; currentLodSurfScaleX = Double.NaN; currentLodSurfScaleZ = Double.NaN; + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_FAR); Utils.printTotalTime(startTime); System.out.println("Injecting primitive image HeightField into Hypersurface..."); @@ -2362,7 +2384,11 @@ private static List> deepCopyGrid(List> src) { 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(); @@ -2374,6 +2400,7 @@ private void captureDataGridAsSource() { private void resetLodDataState() { imageBackedSurface = false; + setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); fullResHeightField = null; lodProcessedLevels = List.of(); activeLodIndex = -1; @@ -2467,14 +2494,14 @@ private static int mapRenderIndexToSource(int renderIndex, int renderSize, int s private double getActiveRenderValue(int row, int column) { if (activeHeightField == null) return 0.0; - int r = Math.max(0, Math.min(row, activeHeightField.height() - 1)); + 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 = Math.max(0, Math.min(row, activeHeightField.height() - 1)); + int r = orientRowIndex(row, activeHeightField.height()); int w = activeHeightField.width(); float[] values = activeHeightField.data(); List out = new ArrayList<>(w); @@ -2490,7 +2517,10 @@ private Double[] getActiveRenderColumn(int column) { int h = activeHeightField.height(); float[] values = activeHeightField.data(); Double[] out = new Double[h]; - for (int y = 0; y < h; y++) out[y] = (double) values[y * w + c]; + for (int visualRow = 0; visualRow < h; visualRow++) { + int sourceRow = orientRowIndex(visualRow, h); + out[visualRow] = (double) values[sourceRow * w + c]; + } return out; } @@ -2544,6 +2574,51 @@ public void setNominalImageWorldExtent(double nominalImageWorldExtent) { } } + 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); + } + + 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; } @@ -2674,6 +2749,7 @@ private void configureTiledAppearance() { } } tiledSurfaceRenderer.setYScale(yScale); + tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); if (colorationMethod == COLORATION.COLOR_BY_IMAGE && lastImage != null) { tiledSurfaceRenderer.setColorByImage(lastImage); 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/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 455ab58a..79859b91 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -61,6 +61,7 @@ public enum ColorMode { private DrawMode drawMode = DrawMode.FILL; private CullFace cullFace = CullFace.BACK; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; private ColorMode colorMode = ColorMode.HEIGHT; private Image image; private int paletteColors = 1530; @@ -176,6 +177,26 @@ public void setCullFace(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.values()) { + tile.clearViews(); + } + lodManager.invalidateRenderedState(); + } + public void setSpecularColor(Color specularColor) { this.specularColor = Objects.requireNonNull(specularColor, "specularColor"); forEachBuiltView(this::applySpecular); @@ -411,6 +432,7 @@ private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { 1, 1, 1, 1, yScale, 1.0, p -> 0.0); view.setDrawMode(drawMode); view.setCullFace(cullFace); + view.setRowOrientation(rowOrientation); view.updateMeshHeightField(field, startX, startZ, cellsX, cellsZ, scaleX, yScale, scaleZ); view.setTranslateX(translateX); From 29e2e5d09a49e6936e1ff4d90d8e19ec2e632c3d Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 14:40:56 -0400 Subject: [PATCH 10/13] fixed inverted Y axis height mapping --- .../panes/HypersurfaceControlsPane.java | 20 ++++++++ .../javafx/javafx3d/HyperSurfacePlotMesh.java | 44 +++++++++++++--- .../javafx/javafx3d/Hypersurface3DPane.java | 37 +++++++++++++- .../javafx3d/SurfaceHeightOrientation.java | 51 +++++++++++++++++++ .../javafx/javafx3d/TiledSurfaceRenderer.java | 22 ++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 9571f470..14039bd1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java @@ -6,6 +6,7 @@ import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.SurfaceHeightOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceRowOrientation; import edu.jhuapl.trinity.javafx.javafx3d.SurfaceUtils; import edu.jhuapl.trinity.javafx.javafx3d.TiledLodManager; @@ -64,6 +65,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private ComboBox cullFaceCombo; private ComboBox colorationCombo; private ComboBox rowOrientationCombo; + private ComboBox heightOrientationCombo; // Processing private ComboBox heightModeCombo; @@ -244,6 +246,14 @@ private TabPane buildTabs() { 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 -> @@ -257,12 +267,22 @@ private TabPane buildTabs() { 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 === 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 4f6cde0a..cad5b86a 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -89,6 +89,7 @@ public class HyperSurfacePlotMesh extends TexturedMesh { 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); @@ -152,6 +153,20 @@ public void setRowOrientation(SurfaceRowOrientation 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); @@ -296,8 +311,10 @@ public void setDirectTextureModeImage(Image image) { } /** - * Colors a direct HeightField mesh by its rendered Y value using a compact rainbow - * palette. Only texture coordinates are updated; point and face buffers are retained. + * 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; @@ -584,7 +601,7 @@ private TriangleMesh createHeightFieldMesh(HeightField heightField, final int globalVertexX = startX + localX; final int sampleX = Math.min(globalVertexX, sourceWidth - 1); final float worldX = (float) (localX * xScale); - final float worldY = source[sourceRow + sampleX] * (float) yScale; + final float worldY = (float) heightOrientation.toWorldY(source[sourceRow + sampleX], yScale); final float u = (float) globalVertexX / (float) sourceWidth; pointRow[p++] = worldX; @@ -771,7 +788,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); @@ -836,6 +853,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(); @@ -854,7 +886,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)); } } @@ -893,7 +925,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)); } } 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 a644033a..eb4a90ec 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -194,6 +194,8 @@ 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; @@ -229,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); @@ -350,6 +353,12 @@ public Hypersurface3DPane(Scene scene) { updateTheMesh(); } }); + surfaceHeightOrientation.addListener((obs, oldValue, newValue) -> { + applySurfaceHeightOrientationToRenderers(); + if (surfPlot != null) { + updateTheMesh(); + } + }); shape3DToCalloutMap = new HashMap<>(); ambientLight = new AmbientLight(Color.WHITE); @@ -1116,6 +1125,7 @@ public void updateTheMesh() { if (surfaceRender) { surfPlot.setRowOrientation(getSurfaceRowOrientation()); + surfPlot.setHeightOrientation(getSurfaceHeightOrientation()); // Primitive HeightField meshes bypass the FXyz Point3D/Face3 object graph. // Shapley coloration temporarily retains the legacy path until its scalar // field is made LOD-aware; image and height coloration use the fast path. @@ -1605,6 +1615,7 @@ private void loadSurf3D() { captureDataGridAsSource(); surfPlot = new HyperSurfacePlotMesh(xWidth, zWidth, 1, 1, yScale, surfScale, vert3DLookup); surfPlot.setRowOrientation(getSurfaceRowOrientation()); + surfPlot.setHeightOrientation(getSurfaceHeightOrientation()); surfPlot.setTextureModeVertices3D(TOTAL_COLORS, colorByHeight, 0.0, 360.0); surfPlot.setDrawMode(DrawMode.FILL); sceneRoot.getChildren().add(surfPlot); @@ -1631,6 +1642,7 @@ private void loadSurf3D() { if (tiledSurfaceRenderer == null) { tiledSurfaceRenderer = new TiledSurfaceRenderer(camera, subScene); tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); tiledSurfaceRenderer.setVisible(false); sceneRoot.getChildren().add(tiledSurfaceRenderer); @@ -2138,7 +2150,7 @@ public void addSemanticMapCollection(SemanticMapCollection semanticMapCollection xSphere.setTranslateX(getWorldWidth() / 2.0); zSphere.setTranslateZ(getWorldDepth() / 2.0); - double poleHeight = surfPlot.getMaxY() * 2; + double poleHeight = surfPlot.getMaxAbsY() * 2; glowLineBox.setWidth(getWorldWidth()); glowLineBox.setHeight(poleHeight); eastPole.setHeight(poleHeight * 1.2); @@ -2603,6 +2615,26 @@ private void applySurfaceRowOrientationToRenderers() { 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)); @@ -2750,6 +2782,7 @@ private void configureTiledAppearance() { } tiledSurfaceRenderer.setYScale(yScale); tiledSurfaceRenderer.setRowOrientation(getSurfaceRowOrientation()); + tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); if (colorationMethod == COLORATION.COLOR_BY_IMAGE && lastImage != null) { tiledSurfaceRenderer.setColorByImage(lastImage); 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/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 79859b91..f71595f5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -62,6 +62,7 @@ public enum ColorMode { 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; @@ -197,6 +198,26 @@ public void setRowOrientation(SurfaceRowOrientation rowOrientation) { 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.values()) { + tile.clearViews(); + } + lodManager.invalidateRenderedState(); + } + public void setSpecularColor(Color specularColor) { this.specularColor = Objects.requireNonNull(specularColor, "specularColor"); forEachBuiltView(this::applySpecular); @@ -433,6 +454,7 @@ private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { view.setDrawMode(drawMode); view.setCullFace(cullFace); view.setRowOrientation(rowOrientation); + view.setHeightOrientation(heightOrientation); view.updateMeshHeightField(field, startX, startZ, cellsX, cellsZ, scaleX, yScale, scaleZ); view.setTranslateX(translateX); From e34cfb6a3596102fa06df45187512395eb98dc88 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 14:51:49 -0400 Subject: [PATCH 11/13] Fixed bad logic path that incorrectly attempted to render raw mesh prior to LOD pyramid being created. --- .../javafx/javafx3d/Hypersurface3DPane.java | 85 +++++++++++++++++-- .../javafx/javafx3d/TiledSurfaceRenderer.java | 2 + 2 files changed, 79 insertions(+), 8 deletions(-) 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 eb4a90ec..24af97fe 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -309,6 +309,14 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} * 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; @@ -1115,6 +1123,17 @@ public void updateTheMesh() { 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); @@ -1754,12 +1773,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; + } } /** @@ -1781,6 +1807,7 @@ private void wireEventHandlers() { if (scene == null) return; // Geometry / scale scene.addEventHandler(HypersurfaceEvent.XWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; this.xWidth = (int) e.object; // When an LOD pyramid is active, its HeightField dimensions own the render // mesh. Do not let GUI synchronization overwrite the constant-world transform. @@ -1792,6 +1819,7 @@ private void wireEventHandlers() { }); scene.addEventHandler(HypersurfaceEvent.ZWIDTH_CHANGED, e -> { + if (syncingGuiControls) return; this.zWidth = (int) e.object; if (surfPlot != null && activeHeightField == null) { surfPlot.setTranslateX(-(xWidth * surfScale) / 2.0); @@ -1801,12 +1829,14 @@ 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(); refreshWorldExtentsAndLodMetadata(); updateTheMesh(); @@ -2268,8 +2298,6 @@ public void setSpheroidAnchor(boolean animate, int index) { } 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)); @@ -2295,6 +2323,14 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { 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; + float[] heights = new float[Math.multiplyExact(sourceWidth, sourceHeight)]; int targetIndex = 0; for (int row = startY; row < endY; row++) { @@ -2388,6 +2424,39 @@ private static List> deepCopyGrid(List> src) { return out; } + /** + * Releases render-side state owned by the previous image before a replacement + * image allocates its full-resolution primitive 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); + } + + fullResHeightField = null; + 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 diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index f71595f5..42884782 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -136,6 +136,7 @@ public void clearSurface() { tileSpecs = List.of(); levels = List.of(); getChildren().clear(); + image = null; visibleTileCount = 0; } @@ -153,6 +154,7 @@ public void dispose() { tileSpecs = List.of(); levels = List.of(); getChildren().clear(); + image = null; } public void setYScale(double yScale) { From 7adb98aedeb8bee8a86392dae27220099d808ec9 Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 15:44:39 -0400 Subject: [PATCH 12/13] moved hot debug prints to optional flag. Optimized mesh nodes to not process mouse events unless hover interactions are enabled. --- .../panes/HypersurfaceControlsPane.java | 7 ++++ .../javafx/javafx3d/Hypersurface3DPane.java | 15 ++++++-- .../javafx/javafx3d/TiledLodManager.java | 36 +++++++++++++------ .../javafx/javafx3d/TiledSurfaceRenderer.java | 25 +++++++++++-- 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/HypersurfaceControlsPane.java index 14039bd1..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 @@ -103,6 +103,7 @@ public class HypersurfaceControlsPane extends LitPathPane { private Spinner activeBuildsSpinner; private Spinner settledBuildsSpinner; private CheckBox geometryBudgetCheck; + private CheckBox verboseLodDiagnosticsCheck; private Spinner triangleBudgetSpinner; private Label lodStatusLabel; @@ -533,6 +534,8 @@ private TabPane buildTabs() { 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); @@ -546,6 +549,7 @@ private TabPane buildTabs() { 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) { @@ -565,6 +569,7 @@ private TabPane buildTabs() { 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), @@ -636,6 +641,8 @@ private void applyLodConfigFromControls() { 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(); 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 24af97fe..c2724951 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -661,6 +661,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"); @@ -1664,6 +1667,7 @@ private void loadSurf3D() { tiledSurfaceRenderer.setHeightOrientation(getSurfaceHeightOrientation()); tiledSurfaceRenderer.setTileCellsL0(tileCellsL0); tiledSurfaceRenderer.setVisible(false); + tiledSurfaceRenderer.setHoverPickingEnabled(hoverInteractionsEnabled); sceneRoot.getChildren().add(tiledSurfaceRenderer); tiledSurfaceRenderer.addEventHandler(MouseEvent.MOUSE_MOVED, e -> { if (!hoverInteractionsEnabled) return; @@ -1943,7 +1947,12 @@ private void wireEventHandlers() { }); // 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); @@ -2929,7 +2938,9 @@ private void rebuildProcessedGridAndRefresh() { lodSummary.append('L').append(i).append('=') .append(level.width()).append('x').append(level.height()); } - System.out.println(lodSummary); + if (tiledSurfaceRenderer == null || tiledSurfaceRenderer.isVerboseDiagnosticsEnabled()) { + System.out.println(lodSummary); + } } // Rendering resolution and physical world size are intentionally independent. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java index 6ccdd81d..af77a814 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -94,6 +94,8 @@ public static final class Config { 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; @@ -121,6 +123,7 @@ public Config copy() { c.finestAllowedLod = finestAllowedLod; c.geometryBudgetEnabled = geometryBudgetEnabled; c.triangleBudget = triangleBudget; + c.verboseDiagnostics = verboseDiagnostics; c.throttleMs = throttleMs; c.debounceMs = debounceMs; c.minDepth = minDepth; @@ -191,13 +194,23 @@ public TiledLodManager(PerspectiveCamera camera, SubScene subScene, Node surface 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)); - System.out.println("Tiled Hypersurface LOD config updated: targetPpc=" + diagnostic("Tiled Hypersurface LOD config updated: targetPpc=" + this.config.targetPixelsPerCell + ", coarsenBelow=" + this.config.lowThreshold + ", refineAbove=" + this.config.highThreshold @@ -282,7 +295,7 @@ public void setSurface(List levels, initialEvaluationTimer.stop(); initialEvaluationTimer.setDuration(Duration.millis(config.initialSettleMs)); initialEvaluationTimer.playFromStart(); - System.out.println("Tiled Hypersurface initial LOD deferred: waiting " + diagnostic("Tiled Hypersurface initial LOD deferred: waiting " + config.initialSettleMs + " ms for stable viewport/camera state"); }); } @@ -335,7 +348,7 @@ public void requestUpdate(LodManager.UpdateReason reason) { settledMode = false; int cancelledRefinements = cancelQueuedRefinements(); if (cancelledRefinements > 0) { - System.out.println("Tiled Hypersurface active camera: paused " + diagnostic("Tiled Hypersurface active camera: paused " + cancelledRefinements + " queued refinements"); } @@ -390,14 +403,14 @@ private void attemptInitialEvaluation() { double height = subScene.getHeight(); if (!(width > 1.0) || !(height > 1.0) || !Double.isFinite(width) || !Double.isFinite(height)) { - System.out.println("Tiled Hypersurface initial LOD still deferred: viewport=" + diagnostic("Tiled Hypersurface initial LOD still deferred: viewport=" + width + "x" + height); scheduleInitialEvaluation(); return; } initialEvaluationPending = false; - System.out.println("Tiled Hypersurface initial LOD released: viewport=" + diagnostic("Tiled Hypersurface initial LOD released: viewport=" + String.format("%.1fx%.1f", width, height)); evaluateAndSchedule(true, true); } @@ -509,7 +522,8 @@ private void evaluateAndSchedule(boolean force, boolean settled) { rebuildTransitionQueue(decisions); lastEvalNanos = System.nanoTime(); - if (desiredChanges > 0 || !pendingTransitions.isEmpty() || force || deferredRefinements > 0) { + if (config.verboseDiagnostics + && (desiredChanges > 0 || !pendingTransitions.isEmpty() || force || deferredRefinements > 0)) { int[] desiredCounts = countDesiredLods(decisions); int queuedCoarsen = 0; int queuedRefine = 0; @@ -537,9 +551,9 @@ private void evaluateAndSchedule(boolean force, boolean settled) { .append(config.geometryBudgetEnabled ? formatTriangleCount(config.triangleBudget) : "OFF") .append(", "); appendLodCounts(sb, desiredCounts); - System.out.println(sb); + diagnostic(sb); if (settled && queuedRefine > 0) { - System.out.println("Tiled Hypersurface settled: refinementReleased=" + diagnostic("Tiled Hypersurface settled: refinementReleased=" + queuedRefine + ", queuedTotal=" + pendingTransitions.size()); } } @@ -619,7 +633,7 @@ private BudgetResult applyGeometryBudget( long exactBudgetedTriangles = totalTriangles(decisions, selectedLods); if (exactBudgetedTriangles > config.triangleBudget) { - System.out.println("Tiled Hypersurface geometry budget warning: requested=" + diagnostic("Tiled Hypersurface geometry budget warning: requested=" + formatTriangleCount(rawTriangles) + ", budget=" + formatTriangleCount(config.triangleBudget) + ", achievable=" + formatTriangleCount(exactBudgetedTriangles) @@ -761,7 +775,7 @@ private void processTransitionPulse() { pendingTransitions.addAll(buildDeferred); - if (applied > 0) { + if (config.verboseDiagnostics && applied > 0) { int[] actualCounts = countAppliedVisibleLods(); StringBuilder sb = new StringBuilder("Tiled Hypersurface transition pulse: visible=") .append(lastVisibleCount).append('/').append(tiles.size()) @@ -776,7 +790,7 @@ private void processTransitionPulse() { .append(", mode=").append(settledMode ? "settled" : "active") .append(", actual="); appendLodCounts(sb, actualCounts); - System.out.println(sb); + diagnostic(sb); } if (pendingTransitions.isEmpty() diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 42884782..7f7cabdd 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -83,6 +83,27 @@ public TiledSurfaceRenderer(PerspectiveCamera camera, SubScene subScene) { 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) { @@ -91,7 +112,7 @@ public void setTileCellsL0(int tileCellsL0) { } if (this.tileCellsL0 == tileCellsL0) return; this.tileCellsL0 = tileCellsL0; - System.out.println("Tiled Hypersurface tile size updated: tileCellsL0=" + tileCellsL0); + diagnostic("Tiled Hypersurface tile size updated: tileCellsL0=" + tileCellsL0); if (!levels.isEmpty()) { rebuildTileLayout(); forceUpdate(); @@ -365,7 +386,7 @@ private void rebuildTileLayout() { maxAbsHeight ); - System.out.println("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + diagnostic("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + " (" + tileSpecs.size() + "), tileCellsL0=" + tileCellsL0 + ", world=" + baseWorldWidth + "x" + baseWorldDepth + ", persistentLodViews=true"); From 892fecf71d0abcfac396373f0d6d285df193bc0e Mon Sep 17 00:00:00 2001 From: Birdasaur Date: Tue, 6 Oct 2026 16:37:39 -0400 Subject: [PATCH 13/13] memory and topology optimizations to reduce mesh computation and load times. --- .../javafx/javafx3d/HyperSurfacePlotMesh.java | 93 +-- .../javafx/javafx3d/Hypersurface3DPane.java | 151 ++++- .../trinity/javafx/javafx3d/SurfaceUtils.java | 20 +- .../javafx/javafx3d/TiledLodManager.java | 602 ++++++++++-------- .../javafx/javafx3d/TiledSurfaceRenderer.java | 165 ++--- 5 files changed, 599 insertions(+), 432 deletions(-) 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 cad5b86a..913092ba 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/HyperSurfacePlotMesh.java @@ -55,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; /** @@ -68,6 +70,11 @@ 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; @@ -579,8 +586,6 @@ private TriangleMesh createHeightFieldMesh(HeightField heightField, TriangleMesh triangleMesh = new TriangleMesh(); triangleMesh.getPoints().resize((int) pointFloatCount); triangleMesh.getTexCoords().resize((int) texFloatCount); - triangleMesh.getFaces().resize((int) faceIntCount); - triangleMesh.getFaceSmoothingGroups().resize((int) faceCount); final float[] source = heightField.data(); final int sourceWidth = heightField.width(); @@ -618,54 +623,50 @@ private TriangleMesh createHeightFieldMesh(HeightField heightField, texRow, 0, texRow.length); } - // Bound temporary allocations while populating the very large persistent JavaFX - // face buffers. Point and texture indices intentionally match one-to-one. - final int rowsPerBlock = 16; - for (int z0 = 0; z0 < cellsZ; z0 += rowsPerBlock) { - final int rows = Math.min(rowsPerBlock, cellsZ - z0); - final int trianglesInBlock = Math.multiplyExact(Math.multiplyExact(rows, cellsX), 2); - final int[] faces = new int[Math.multiplyExact(trianglesInBlock, 6)]; - final int[] groups = new int[trianglesInBlock]; - Arrays.fill(groups, 1); - - int fi = 0; - for (int localZ = 0; localZ < rows; localZ++) { - final int z = z0 + localZ; - final int rowStart = z * vertsX; - for (int x = 0; x < cellsX; x++) { - final int p00 = rowStart + x; - final int p01 = p00 + 1; - final int p10 = p00 + vertsX; - final int p11 = p10 + 1; - - // Winding is intentionally reversed so the surface front faces Y-. - // This makes CullFace.BACK preserve the normal top-down view. - 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; - } - } - - final int firstTriangle = z0 * cellsX * 2; - triangleMesh.getFaces().set(firstTriangle * 6, - faces, 0, faces.length); - triangleMesh.getFaceSmoothingGroups().set(firstTriangle, - groups, 0, groups.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(); 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 c2724951..3f63e5b9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hypersurface3DPane.java @@ -287,8 +287,15 @@ public enum COLORATION {COLOR_BY_IMAGE, COLOR_BY_FEATURE, COLOR_BY_SHAPLEY} /** Tiled image rendering uses a fixed L0..L4 power-of-two pyramid. */ private static final int TILED_LOD_LEVEL_COUNT = 5; - /** Full-resolution height data (primitive). Derived from originalGrid or other data sources. */ - private HeightField fullResHeightField = null; + /** 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(); @@ -2306,6 +2313,66 @@ public void setSpheroidAnchor(boolean animate, int index) { double z = index * surfScale; } + /** + * Builds the raw image L0 directly from PixelReader without first allocating a + * full-resolution float raster. For sources above maxRender this reduces both + * persistent heap usage and source-pixel reads substantially. + */ + private static HeightField buildImageRawL0(PixelReader pixelReader, + int startX, int startY, + int sourceWidth, int sourceHeight, + int maxRender) { + int[] dims = SurfaceUtils.computeL0Dimensions(sourceWidth, sourceHeight, maxRender); + int targetWidth = dims[0]; + int targetHeight = dims[1]; + float[] out = new float[Math.multiplyExact(targetWidth, targetHeight)]; + + if (targetWidth == sourceWidth && targetHeight == sourceHeight) { + int index = 0; + for (int y = 0; y < sourceHeight; y++) { + int sourceY = startY + y; + for (int x = 0; x < sourceWidth; x++) { + out[index++] = grayscale(pixelReader.getArgb(startX + x, sourceY)); + } + } + return new HeightField(targetWidth, targetHeight, out); + } + + double xRatio = sourceWidth / (double) targetWidth; + double yRatio = sourceHeight / (double) targetHeight; + for (int y = 0; y < targetHeight; y++) { + double gy = (y + 0.5) * yRatio - 0.5; + gy = Math.max(0.0, Math.min(sourceHeight - 1.0, gy)); + int y0 = (int) Math.floor(gy); + int y1 = Math.min(sourceHeight - 1, y0 + 1); + double ty = y0 == y1 ? 0.0 : gy - y0; + + for (int x = 0; x < targetWidth; x++) { + double gx = (x + 0.5) * xRatio - 0.5; + gx = Math.max(0.0, Math.min(sourceWidth - 1.0, gx)); + int x0 = (int) Math.floor(gx); + int x1 = Math.min(sourceWidth - 1, x0 + 1); + double tx = x0 == x1 ? 0.0 : gx - x0; + + double c00 = grayscale(pixelReader.getArgb(startX + x0, startY + y0)); + double c10 = grayscale(pixelReader.getArgb(startX + x1, startY + y0)); + double c01 = grayscale(pixelReader.getArgb(startX + x0, startY + y1)); + double c11 = grayscale(pixelReader.getArgb(startX + x1, startY + y1)); + double top = c00 + (c10 - c00) * tx; + double bottom = c01 + (c11 - c01) * tx; + out[y * targetWidth + x] = (float) (top + (bottom - top) * ty); + } + } + return new HeightField(targetWidth, targetHeight, out); + } + + private static float grayscale(int argb) { + int red = (argb >> 16) & 0xFF; + int green = (argb >> 8) & 0xFF; + int blue = argb & 0xFF; + return (float) (((red + green + blue) / 3.0) / 255.0); + } + private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { final int imageWidth = (int) image.getWidth(); final int imageHeight = (int) image.getHeight(); @@ -2323,7 +2390,7 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { } long startTime = System.nanoTime(); - System.out.println("Mapping Image Raster directly to primitive HeightField: " + System.out.println("Mapping Image Raster directly to capped primitive L0: " + sourceWidth + "x" + sourceHeight); PixelReader pixelReader = image.getPixelReader(); @@ -2340,27 +2407,23 @@ private void tessellateImage(Image image, int x1, int y1, int x2, int y2) { releaseImageBackedStateForReplacement(); lastImage = image; - float[] heights = new float[Math.multiplyExact(sourceWidth, sourceHeight)]; - int targetIndex = 0; - for (int row = startY; row < endY; row++) { - for (int column = startX; column < endX; column++) { - int argb = pixelReader.getArgb(column, row); - int red = (argb >> 16) & 0xFF; - int green = (argb >> 8) & 0xFF; - int blue = argb & 0xFF; - heights[targetIndex++] = (float) (((red + green + blue) / 3.0) / 255.0); - } - } - - // Image-backed hypersurfaces use the primitive HeightField as the authoritative - // source. Do not manufacture one FeatureVector and one boxed Double per pixel. + // 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; - fullResHeightField = new HeightField(sourceWidth, sourceHeight, heights); // A new primitive source invalidates only state derived from the previous source. lodProcessedLevels = List.of(); @@ -2435,7 +2498,7 @@ private static List> deepCopyGrid(List> src) { /** * Releases render-side state owned by the previous image before a replacement - * image allocates its full-resolution primitive HeightField. Keeping this separate + * 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. @@ -2452,7 +2515,11 @@ private void releaseImageBackedStateForReplacement() { paintMeshView.setVisible(false); } - fullResHeightField = null; + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; lodProcessedLevels = List.of(); activeLodIndex = -1; activeHeightField = null; @@ -2485,13 +2552,21 @@ private void captureDataGridAsSource() { return; } originalGrid = deepCopyGrid(dataGrid); - fullResHeightField = null; + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; } private void resetLodDataState() { imageBackedSurface = false; setSourceDefaultRowOrientation(SurfaceRowOrientation.FIRST_ROW_NEAR); - fullResHeightField = null; + processingSourceHeightField = null; + imageSourceStartX = 0; + imageSourceStartY = 0; + imageSourceWidth = 0; + imageSourceHeight = 0; lodProcessedLevels = List.of(); activeLodIndex = -1; activeHeightField = null; @@ -2506,14 +2581,16 @@ private void resetLodDataState() { } private int getSourceWidth() { - if (fullResHeightField != null) return fullResHeightField.width(); + 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 (fullResHeightField != null) return fullResHeightField.height(); + 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); @@ -2658,7 +2735,7 @@ public void setNominalImageWorldExtent(double nominalImageWorldExtent) { } if (Double.compare(this.nominalImageWorldExtent, nominalImageWorldExtent) == 0) return; this.nominalImageWorldExtent = nominalImageWorldExtent; - if (imageBackedSurface && fullResHeightField != null) { + if (imageBackedSurface && processingSourceHeightField != null) { refreshWorldExtentsAndLodMetadata(); updateTheMesh(); } @@ -2799,6 +2876,14 @@ public void setMaxRenderResolution(int maxRenderResolution) { } if (this.maxRenderResolution == maxRenderResolution) return; this.maxRenderResolution = maxRenderResolution; + if (imageBackedSurface && lastImage != null && imageSourceWidth > 0 && imageSourceHeight > 0) { + PixelReader reader = lastImage.getPixelReader(); + if (reader != null) { + processingSourceHeightField = buildImageRawL0(reader, + imageSourceStartX, imageSourceStartY, + imageSourceWidth, imageSourceHeight, maxRenderResolution); + } + } rebuildProcessedGridAndRefresh(); } @@ -2896,18 +2981,20 @@ private void configureTiledPyramid() { } private void rebuildProcessedGridAndRefresh() { - // Option B: cap/resample full-resolution source to L0, process L0 once, - // then derive all lower LODs from that processed L0. + // 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. // - // Image-backed surfaces may already provide the authoritative source directly as - // a primitive HeightField. Boxed-grid-backed surfaces continue to populate + // Boxed-grid-backed surfaces continue to populate // originalGrid and are converted lazily here for compatibility. - if (fullResHeightField == null) { + if (processingSourceHeightField == null) { if (originalGrid == null || originalGrid.isEmpty()) return; - fullResHeightField = SurfaceUtils.toHeightField(originalGrid); + processingSourceHeightField = SurfaceUtils.toHeightField(originalGrid); } - HeightField l0Raw = SurfaceUtils.buildL0Raw(fullResHeightField, maxRenderResolution); + HeightField l0Raw = imageBackedSurface + ? processingSourceHeightField + : SurfaceUtils.buildL0Raw(processingSourceHeightField, maxRenderResolution); SurfaceUtils.Smoothing sm = smoothingEnabled ? smoothingMethod 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 eb1b1023..9e652e87 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java @@ -657,6 +657,10 @@ public static HeightField downsample2x(HeightField src) { 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]); } @@ -965,7 +969,19 @@ public static HeightField processL0( double toneParam ) { Objects.requireNonNull(l0Raw, "l0Raw"); - HeightField smoothed = smoothCopy(l0Raw, smoothing, radius, iterations, sigma); - return toneMapCopy(smoothed, toneMap, toneParam); + 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/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java index af77a814..655df7f9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java @@ -10,22 +10,18 @@ import javafx.util.Duration; import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; +import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.PriorityQueue; -import java.util.Set; import java.util.function.Consumer; /** * Camera/frustum-aware per-tile LOD selector and transition scheduler. * - *

The manager owns desired LOD state, hysteresis, frustum decisions, the - * prioritized transition queue, and per-pulse transition budgets. It does not - * construct JavaFX meshes; transitions are delegated to the renderer through - * {@link TransitionApplier}.

+ *

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 { @@ -56,6 +52,10 @@ public TileSpec(int id, int column, int row, 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) { } @@ -84,6 +84,12 @@ 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; @@ -147,13 +153,31 @@ public Config copy() { (obs, oldValue, newValue) -> requestUpdate(LodManager.UpdateReason.RESIZE); private final PriorityQueue pendingTransitions = new PriorityQueue<>(); - /** Stable screen-space/hysteresis state before aggregate budget constraints. */ - private final Map qualityLodByTile = new HashMap<>(); - /** Final target state after budget and interaction policy. */ - private final Map desiredLodByTile = new HashMap<>(); - /** Renderer state: where each tile has actually transitioned to. */ - private final Map appliedLodByTile = new HashMap<>(); - private final Set visibleTileIds = new HashSet<>(); + 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 @@ -162,14 +186,12 @@ public void handle(long now) { } }; - private Config config = new Config(); - private List levels = List.of(); - private List tiles = List.of(); private double baseWorldWidth = Double.NaN; private double baseWorldDepth = Double.NaN; private double heightRadius = 0.0; private long lastEvalNanos; - private Consumer> onDecisions; + private Consumer> compatibilityDecisionListener; + private VisibilityApplier visibilityApplier; private TransitionApplier transitionApplier; private GeometryCostProvider geometryCostProvider; private boolean transitionTimerRunning; @@ -239,8 +261,13 @@ public Config getConfigCopy() { return config.copy(); } + /** Compatibility API; normal tiled rendering uses setVisibilityApplier(). */ public void setOnDecisions(Consumer> onDecisions) { - this.onDecisions = onDecisions; + this.compatibilityDecisionListener = onDecisions; + } + + public void setVisibilityApplier(VisibilityApplier visibilityApplier) { + this.visibilityApplier = visibilityApplier; } public void setTransitionApplier(TransitionApplier transitionApplier) { @@ -279,10 +306,7 @@ public void setSurface(List levels, this.baseWorldWidth = baseWorldWidth; this.baseWorldDepth = baseWorldDepth; this.heightRadius = Math.max(0.0, heightRadius); - qualityLodByTile.clear(); - desiredLodByTile.clear(); - appliedLodByTile.clear(); - visibleTileIds.clear(); + initializeTileState(this.tiles); pendingTransitions.clear(); lastRawRequestedTriangles = 0L; lastBudgetedTargetTriangles = 0L; @@ -304,10 +328,7 @@ public void clearSurface() { runOnFx(() -> { levels = List.of(); tiles = List.of(); - qualityLodByTile.clear(); - desiredLodByTile.clear(); - appliedLodByTile.clear(); - visibleTileIds.clear(); + clearTileState(); pendingTransitions.clear(); lastRawRequestedTriangles = 0L; lastBudgetedTargetTriangles = 0L; @@ -319,13 +340,10 @@ public void clearSurface() { }); } - /** - * Tells the manager that all renderer-side LOD views were discarded while - * preserving the current surface/pyramid configuration. - */ + /** Tells the manager that renderer-side LOD views were discarded. */ public void invalidateRenderedState() { runOnFx(() -> { - appliedLodByTile.clear(); + Arrays.fill(appliedLodByTile, -1); pendingTransitions.clear(); stopTransitionTimer(); }); @@ -342,9 +360,6 @@ public void requestUpdate(LodManager.UpdateReason reason) { debounceTimer.setDuration(Duration.millis(config.debounceMs)); debounceTimer.playFromStart(); - // Camera interaction takes priority over refinement. Stop any - // refinement work released by a previous settled evaluation - // immediately, even if this camera event is inside the throttle window. settledMode = false; int cancelledRefinements = cancelQueuedRefinements(); if (cancelledRefinements > 0) { @@ -360,11 +375,6 @@ public void requestUpdate(LodManager.UpdateReason reason) { }); } - /** - * Force a settled evaluation. During initial surface setup this is deliberately - * deferred until the viewport/camera has remained quiet for the configured - * initial-settle interval. - */ public void forceUpdate() { runOnFx(() -> { debounceTimer.stop(); @@ -390,6 +400,74 @@ public void dispose() { }); } + 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)); @@ -416,7 +494,7 @@ private void attemptInitialEvaluation() { } private void evaluateAndSchedule(boolean force, boolean settled) { - if (levels.isEmpty() || tiles.isEmpty()) return; + if (levels.isEmpty() || tiles.isEmpty() || stateSize == 0) return; if (!Double.isFinite(baseWorldWidth) || !Double.isFinite(baseWorldDepth)) return; final double viewportWidth = subScene.getWidth(); @@ -427,104 +505,98 @@ private void evaluateAndSchedule(boolean force, boolean settled) { final double tanHalfFov = Math.tan(fovRadians * 0.5); if (!(tanHalfFov > 0.0)) return; - ArrayList decisions = new ArrayList<>(tiles.size()); - Map visibleLods = new HashMap<>(); - Map selectedLods = new HashMap<>(); - int desiredChanges = 0; + Arrays.fill(visibleByTile, false); + Arrays.fill(selectedLodByTile, -1); + Arrays.fill(budgetCoarsenedByTile, false); + int deferredRefinements = 0; int visibleCount = 0; - visibleTileIds.clear(); for (TileSpec tile : tiles) { - CameraTile cameraTile = cameraTile(tile); - boolean visible = isVisible(cameraTile, aspect, tanHalfFov); - int previousQuality = qualityLodByTile.getOrDefault(tile.id, - appliedLodByTile.getOrDefault(tile.id, -1)); + 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) { - decisions.add(new TileDecision(tile.id, false, - previousQuality >= 0 ? previousQuality : levels.size() - 1, - cameraTile.depth, 0.0, - screenDistance(cameraTile, aspect, tanHalfFov))); + selectedLodByTile[id] = previousQuality >= 0 + ? previousQuality : levels.size() - 1; + depthByTile[id] = cameraDepthByTile[id]; + pixelsPerCellByTile[id] = 0.0; + screenDistanceByTile[id] = screenDistance(id, aspect, tanHalfFov); continue; } visibleCount++; - visibleTileIds.add(tile.id); double depth = Math.max(config.minDepth, - cameraTile.depth - cameraTile.radius * 0.35); + cameraDepthByTile[id] - cameraRadiusByTile[id] * 0.35); int selected = chooseLod(previousQuality, depth, viewportWidth, viewportHeight, aspect, tanHalfFov); - selectedLods.put(tile.id, selected); - visibleLods.put(gridKey(tile.column, tile.row), selected); - decisions.add(new TileDecision(tile.id, true, selected, depth, - pixelsPerCell(levels.get(selected), depth, - viewportWidth, viewportHeight, aspect, tanHalfFov), - screenDistance(cameraTile, 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(selectedLods, visibleLods); - for (int i = 0; i < decisions.size(); i++) { - TileDecision d = decisions.get(i); - if (!d.visible()) continue; - int lod = selectedLods.getOrDefault(d.tileId(), d.lodIndex()); - if (lod != d.lodIndex()) { - decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), - pixelsPerCell(levels.get(lod), d.depth(), - viewportWidth, viewportHeight, aspect, tanHalfFov), - d.screenDistance())); - } - } + enforceNeighborDelta(); + refreshPixelsPerCell(viewportWidth, viewportHeight, aspect, tanHalfFov); } - for (TileDecision decision : decisions) { - if (decision.visible()) { - qualityLodByTile.put(decision.tileId(), decision.lodIndex()); + // Preserve hysteresis quality before aggregate geometry budgeting. + lastVisibleCount = visibleCount; + for (TileSpec tile : tiles) { + int id = tile.id; + if (isVisibleTile(id)) { + qualityLodByTile[id] = selectedLodByTile[id]; } } - BudgetResult budgetResult = applyGeometryBudget( - decisions, selectedLods, visibleLods, viewportWidth, viewportHeight, aspect, tanHalfFov); - lastRawRequestedTriangles = budgetResult.rawTriangles(); - lastBudgetedTargetTriangles = budgetResult.budgetedTriangles(); - lastBudgetCoarsenedTiles = budgetResult.coarsenedTiles(); + applyGeometryBudget(viewportWidth, viewportHeight, aspect, tanHalfFov); if (!settled) { - for (int i = 0; i < decisions.size(); i++) { - TileDecision d = decisions.get(i); - if (!d.visible()) continue; - int applied = appliedLodByTile.getOrDefault(d.tileId(), -1); - if (applied >= 0 && d.lodIndex() < applied) { + for (TileSpec tile : tiles) { + int id = tile.id; + if (!isVisibleTile(id)) continue; + int applied = appliedLodByTile[id]; + if (applied >= 0 && selectedLodByTile[id] < applied) { deferredRefinements++; - decisions.set(i, new TileDecision(d.tileId(), true, applied, d.depth(), - pixelsPerCell(levels.get(applied), d.depth(), - viewportWidth, viewportHeight, aspect, tanHalfFov), - d.screenDistance())); - selectedLods.put(d.tileId(), applied); + selectedLodByTile[id] = applied; + pixelsPerCellByTile[id] = pixelsPerCell(levels.get(applied), depthByTile[id], + viewportWidth, viewportHeight, aspect, tanHalfFov); } } } - desiredChanges = 0; - for (TileDecision decision : decisions) { - if (!decision.visible()) continue; - int previousDesired = desiredLodByTile.getOrDefault(decision.tileId(), - appliedLodByTile.getOrDefault(decision.tileId(), -1)); - if (decision.lodIndex() != previousDesired) desiredChanges++; - desiredLodByTile.put(decision.tileId(), decision.lodIndex()); + 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]; } - lastVisibleCount = visibleCount; lastDeferredRefinements = deferredRefinements; settledMode = settled; - if (onDecisions != null) onDecisions.accept(List.copyOf(decisions)); - rebuildTransitionQueue(decisions); + + 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(decisions); + int[] desiredCounts = countDesiredLods(); int queuedCoarsen = 0; int queuedRefine = 0; int queuedInitial = 0; @@ -562,76 +634,59 @@ private void evaluateAndSchedule(boolean force, boolean settled) { else stopTransitionTimer(); } - private BudgetResult applyGeometryBudget( - List decisions, - Map selectedLods, - Map visibleLods, - double viewportWidth, double viewportHeight, - double aspect, double tanHalfFov) { + private void applyGeometryBudget(double viewportWidth, double viewportHeight, + double aspect, double tanHalfFov) { + long rawTriangles = totalSelectedTriangles(); + lastRawRequestedTriangles = rawTriangles; + lastBudgetedTargetTriangles = rawTriangles; + lastBudgetCoarsenedTiles = 0; - long rawTriangles = totalTriangles(decisions, selectedLods); if (!config.geometryBudgetEnabled || config.triangleBudget <= 0L || geometryCostProvider == null || rawTriangles <= config.triangleBudget) { - return new BudgetResult(rawTriangles, rawTriangles, 0); - } - - Map decisionByTile = new HashMap<>(); - for (TileDecision decision : decisions) { - if (decision.visible()) decisionByTile.put(decision.tileId(), decision); + return; } long triangles = rawTriangles; - Set coarsenedTiles = new HashSet<>(); - int guard = Math.max(1, decisionByTile.size() * Math.max(1, levels.size()) * 2); + int guard = Math.max(1, lastVisibleCount * Math.max(1, levels.size()) * 2); while (triangles > config.triangleBudget && guard-- > 0) { - BudgetCandidate candidate = null; + int bestTileId = -1; + int bestToLod = -1; + long bestSavings = 0L; for (TileSpec tile : tiles) { - Integer currentObject = selectedLods.get(tile.id); - if (currentObject == null) continue; - int current = currentObject; + 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, visibleLods)) continue; + if (!budgetNeighborCompatible(tile, proposed)) continue; - long currentCost = triangleCost(tile.id, current); - long proposedCost = triangleCost(tile.id, proposed); - long savings = currentCost - proposedCost; + long savings = triangleCost(id, current) - triangleCost(id, proposed); if (savings <= 0L) continue; - TileDecision decision = decisionByTile.get(tile.id); - if (decision == null) continue; - BudgetCandidate next = new BudgetCandidate(tile, decision, current, proposed, savings); - if (candidate == null || next.isLowerPriorityThan(candidate)) { - candidate = next; + if (bestTileId < 0 || lowerBudgetPriority(id, savings, bestTileId, bestSavings)) { + bestTileId = id; + bestToLod = proposed; + bestSavings = savings; } } - if (candidate == null) break; - - selectedLods.put(candidate.tile().id, candidate.toLod()); - visibleLods.put(gridKey(candidate.tile().column, candidate.tile().row), candidate.toLod()); - coarsenedTiles.add(candidate.tile().id); - triangles -= candidate.savings(); - } - - for (int i = 0; i < decisions.size(); i++) { - TileDecision d = decisions.get(i); - if (!d.visible()) continue; - int lod = selectedLods.getOrDefault(d.tileId(), d.lodIndex()); - if (lod != d.lodIndex()) { - decisions.set(i, new TileDecision(d.tileId(), true, lod, d.depth(), - pixelsPerCell(levels.get(lod), d.depth(), - viewportWidth, viewportHeight, aspect, tanHalfFov), - d.screenDistance())); + if (bestTileId < 0) break; + selectedLodByTile[bestTileId] = bestToLod; + if (!budgetCoarsenedByTile[bestTileId]) { + budgetCoarsenedByTile[bestTileId] = true; + lastBudgetCoarsenedTiles++; } + triangles -= bestSavings; } - long exactBudgetedTriangles = totalTriangles(decisions, selectedLods); + refreshPixelsPerCell(viewportWidth, viewportHeight, aspect, tanHalfFov); + long exactBudgetedTriangles = totalSelectedTriangles(); + lastBudgetedTargetTriangles = exactBudgetedTriangles; if (exactBudgetedTriangles > config.triangleBudget) { diagnostic("Tiled Hypersurface geometry budget warning: requested=" + formatTriangleCount(rawTriangles) @@ -639,16 +694,26 @@ private BudgetResult applyGeometryBudget( + ", achievable=" + formatTriangleCount(exactBudgetedTriangles) + ", neighborDelta=" + config.maxNeighborLodDelta); } - return new BudgetResult(rawTriangles, exactBudgetedTriangles, coarsenedTiles.size()); } - private long totalTriangles(List decisions, Map lods) { + 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 (TileDecision decision : decisions) { - if (!decision.visible()) continue; - int lod = lods.getOrDefault(decision.tileId(), decision.lodIndex()); - total += triangleCost(decision.tileId(), lod); + 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; } @@ -658,21 +723,16 @@ private long triangleCost(int tileId, int lod) { return Math.max(0L, geometryCostProvider.trianglesFor(tileId, lod)); } - private boolean budgetNeighborCompatible(TileSpec tile, int proposedLod, - Map visibleLods) { + private boolean budgetNeighborCompatible(TileSpec tile, int proposedLod) { if (config.maxNeighborLodDelta < 0) return true; - return budgetNeighborCompatible(proposedLod, - visibleLods.get(gridKey(tile.column - 1, tile.row))) - && budgetNeighborCompatible(proposedLod, - visibleLods.get(gridKey(tile.column + 1, tile.row))) - && budgetNeighborCompatible(proposedLod, - visibleLods.get(gridKey(tile.column, tile.row - 1))) - && budgetNeighborCompatible(proposedLod, - visibleLods.get(gridKey(tile.column, tile.row + 1))); + 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, Integer neighborLod) { - return neighborLod == null + private boolean budgetNeighborCompatible(int proposedLod, int neighborLod) { + return neighborLod < 0 || proposedLod <= neighborLod + config.maxNeighborLodDelta; } @@ -684,28 +744,23 @@ private int cancelQueuedRefinements() { return removed; } - private void rebuildTransitionQueue(List decisions) { + private void rebuildTransitionQueue() { pendingTransitions.clear(); - for (TileDecision decision : decisions) { - if (!decision.visible()) continue; - int from = appliedLodByTile.getOrDefault(decision.tileId(), -1); - int to = decision.lodIndex(); - if (from == to) continue; + 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) { - // Coarsening reduces render cost, so it is highest priority. - priorityClass = 0; - } else if (from < 0) { - // Newly visible/uninitialized tile. - priorityClass = 1; - } else { - // Refinement can trail slightly behind camera motion. - priorityClass = 2; - } + if (from >= 0 && to > from) priorityClass = 0; + else if (from < 0) priorityClass = 1; + else priorityClass = 2; + pendingTransitions.add(new LodTransition( - decision.tileId(), from, to, priorityClass, - decision.screenDistance(), decision.depth(), decision.pixelsPerCell())); + id, from, to, priorityClass, + screenDistanceByTile[id], depthByTile[id], pixelsPerCellByTile[id])); } } @@ -724,11 +779,8 @@ private void processTransitionPulse() { int refined = 0; int initial = 0; - // When activeBuildsPerPulse is zero, uncached transitions are deferred rather - // than forcing a TriangleMesh construction during camera interaction. We still - // scan the current queue once so cached LOD switches can proceed normally. - final int transitionsAvailableAtStart = pendingTransitions.size(); - final List buildDeferred = new ArrayList<>(); + int transitionsAvailableAtStart = pendingTransitions.size(); + buildDeferredTransitions.clear(); int examined = 0; while (applied < transitionBudget @@ -737,30 +789,28 @@ private void processTransitionPulse() { LodTransition transition = pendingTransitions.poll(); examined++; - Integer desired = desiredLodByTile.get(transition.tileId()); - if (desired == null || desired != transition.toLod()) { - continue; // stale request superseded by a newer camera evaluation - } + int id = transition.tileId(); + if (id < 0 || id >= stateSize) continue; + int desired = desiredLodByTile[id]; + if (desired < 0 || desired != transition.toLod()) continue; - int actualFrom = appliedLodByTile.getOrDefault(transition.tileId(), -1); + int actualFrom = appliedLodByTile[id]; if (actualFrom == transition.toLod()) continue; LodTransition actualTransition = actualFrom == transition.fromLod() ? transition - : new LodTransition(transition.tileId(), actualFrom, transition.toLod(), + : 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()) { - // With a zero/exhausted build budget the renderer reports an uncached - // transition as not applied. Preserve it for a later settled pulse. - if (!allowBuild) buildDeferred.add(actualTransition); + if (!allowBuild) buildDeferredTransitions.add(actualTransition); continue; } - appliedLodByTile.put(actualTransition.tileId(), actualTransition.toLod()); + appliedLodByTile[id] = actualTransition.toLod(); applied++; if (result.built()) built++; if (result.cacheHit()) cacheHits++; @@ -768,12 +818,10 @@ private void processTransitionPulse() { else if (actualTransition.isCoarsening()) coarsened++; else if (actualTransition.isRefining()) refined++; - // Building TriangleMesh buffers is substantially more expensive than - // toggling cached MeshViews. Keep first-use construction tightly bounded. if (buildBudget > 0 && built >= buildBudget) break; } - pendingTransitions.addAll(buildDeferred); + pendingTransitions.addAll(buildDeferredTransitions); if (config.verboseDiagnostics && applied > 0) { int[] actualCounts = countAppliedVisibleLods(); @@ -795,9 +843,6 @@ private void processTransitionPulse() { if (pendingTransitions.isEmpty() || (!settledMode && buildBudget == 0 && applied == 0)) { - // Avoid polling the same uncached transitions every JavaFX pulse when - // active mesh construction is explicitly disabled. A later camera - // evaluation or the settled evaluation will restart the timer. stopTransitionTimer(); } } @@ -863,18 +908,20 @@ private double pixelsPerCell(LodManager.LodLevel level, double depth, return Math.min(cellX, cellZ) * pixelsPerWorldUnit; } - private CameraTile cameraTile(TileSpec tile) { + private void computeCameraTile(TileSpec tile, int id) { Point3D scenePoint = surfaceNode.localToScene(tile.centerX(), 0.0, tile.centerZ()); Point3D cameraPoint = camera.sceneToLocal(scenePoint); - double radius = Math.sqrt(tile.halfWidth() * tile.halfWidth() + 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); - return new CameraTile(cameraPoint.getX(), cameraPoint.getY(), cameraPoint.getZ(), radius); } - private boolean isVisible(CameraTile ct, double aspect, double tanHalfFov) { - double z = ct.depth; - double radius = ct.radius; + 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; @@ -889,12 +936,12 @@ private boolean isVisible(CameraTile ct, double aspect, double tanHalfFov) { halfWidth = depthForExtent * tanHalfFov; halfHeight = halfWidth / Math.max(1.0e-9, aspect); } - return Math.abs(ct.x) <= halfWidth + radius - && Math.abs(ct.y) <= halfHeight + radius; + return Math.abs(cameraXByTile[id]) <= halfWidth + radius + && Math.abs(cameraYByTile[id]) <= halfHeight + radius; } - private double screenDistance(CameraTile ct, double aspect, double tanHalfFov) { - double depth = Math.max(config.minDepth, ct.depth); + private double screenDistance(int id, double aspect, double tanHalfFov) { + double depth = Math.max(config.minDepth, cameraDepthByTile[id]); double halfWidth; double halfHeight; if (camera.isVerticalFieldOfView()) { @@ -904,71 +951,104 @@ private double screenDistance(CameraTile ct, double aspect, double tanHalfFov) { halfWidth = depth * tanHalfFov; halfHeight = halfWidth / Math.max(1.0e-9, aspect); } - double nx = ct.x / Math.max(config.minDepth, halfWidth); - double ny = ct.y / Math.max(config.minDepth, halfHeight); + 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(Map selectedLods, - Map visibleLods) { - if (visibleLods.isEmpty()) return; - + private void enforceNeighborDelta() { boolean changed; int guard = Math.max(1, levels.size() * 2); do { changed = false; for (TileSpec tile : tiles) { - Integer lodObject = selectedLods.get(tile.id); - if (lodObject == null) continue; - int lod = lodObject; + int id = tile.id; + if (!isVisibleTile(id)) continue; + int lod = selectedLodByTile[id]; int refined = lod; - refined = refineAgainstNeighbor(refined, - visibleLods.get(gridKey(tile.column - 1, tile.row))); - refined = refineAgainstNeighbor(refined, - visibleLods.get(gridKey(tile.column + 1, tile.row))); - refined = refineAgainstNeighbor(refined, - visibleLods.get(gridKey(tile.column, tile.row - 1))); - refined = refineAgainstNeighbor(refined, - visibleLods.get(gridKey(tile.column, tile.row + 1))); + 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) { - selectedLods.put(tile.id, refined); - visibleLods.put(gridKey(tile.column, tile.row), refined); + selectedLodByTile[id] = refined; changed = true; } } } while (changed && --guard > 0); } - private int refineAgainstNeighbor(int lod, Integer neighborLod) { - if (neighborLod == null) return lod; + 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 (Integer tileId : visibleTileIds) { - Integer lod = appliedLodByTile.get(tileId); - if (lod != null && lod >= 0 && lod < counts.length) { - counts[lod]++; - } + 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(List decisions) { + private int[] countDesiredLods() { int[] counts = new int[levels.size()]; - for (TileDecision decision : decisions) { - if (decision.visible() - && decision.lodIndex() >= 0 - && decision.lodIndex() < counts.length) { - counts[decision.lodIndex()]++; - } + 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(", "); @@ -1014,10 +1094,6 @@ private static void validateConfig(Config config) { } } - private static long gridKey(int column, int row) { - return (((long) column) << 32) ^ (row & 0xffffffffL); - } - private static void runOnFx(Runnable runnable) { if (Platform.isFxApplicationThread()) runnable.run(); else Platform.runLater(runnable); @@ -1032,22 +1108,4 @@ private static String formatTriangleCount(long triangles) { } return Long.toString(triangles); } - - private record BudgetResult(long rawTriangles, long budgetedTriangles, int coarsenedTiles) { } - - private record BudgetCandidate(TileSpec tile, TileDecision decision, - int fromLod, int toLod, long savings) { - boolean isLowerPriorityThan(BudgetCandidate other) { - // Larger normalized screen distance is less visually important. - int screenCmp = Double.compare(decision.screenDistance(), other.decision.screenDistance()); - if (screenCmp != 0) return screenCmp > 0; - // At comparable screen position, farther tiles lose detail first. - int depthCmp = Double.compare(decision.depth(), other.decision.depth()); - if (depthCmp != 0) return depthCmp > 0; - // Prefer the demotion that buys the most triangles when visual priority ties. - return savings > other.savings; - } - } - - private record CameraTile(double x, double y, double depth, double radius) { } } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java index 7f7cabdd..c0eca711 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java @@ -10,9 +10,7 @@ import javafx.scene.shape.DrawMode; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Objects; /** @@ -45,12 +43,21 @@ public enum ColorMode { 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 final Map tilesById = new HashMap<>(); + private TileRenderState[] tilesById = new TileRenderState[0]; + private TileLodInfo[][] tileLodInfoByTile = new TileLodInfo[0][]; private List levels = List.of(); private List tileSpecs = List.of(); @@ -79,7 +86,7 @@ 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.setOnDecisions(this::applyVisibilityDecisions); + this.lodManager.setVisibilityApplier(this::applyVisibilityDecisions); this.lodManager.setTransitionApplier(this::applyTransition); this.lodManager.setGeometryCostProvider(this::triangleCountForTileId); setPickOnBounds(false); @@ -153,7 +160,8 @@ public void setPyramid(List levels, public void clearSurface() { lodManager.clearSurface(); - tilesById.clear(); + tilesById = new TileRenderState[0]; + tileLodInfoByTile = new TileLodInfo[0][]; tileSpecs = List.of(); levels = List.of(); getChildren().clear(); @@ -171,7 +179,8 @@ public void forceUpdate() { public void dispose() { lodManager.dispose(); - tilesById.clear(); + tilesById = new TileRenderState[0]; + tileLodInfoByTile = new TileLodInfo[0][]; tileSpecs = List.of(); levels = List.of(); getChildren().clear(); @@ -184,8 +193,8 @@ public void setYScale(double yScale) { // Geometry Y coordinates changed, so all cached per-LOD MeshViews are invalid. getChildren().clear(); - for (TileRenderState tile : tilesById.values()) { - tile.clearViews(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); } lodManager.invalidateRenderedState(); forceUpdate(); @@ -215,8 +224,8 @@ public void setRowOrientation(SurfaceRowOrientation rowOrientation) { // 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.values()) { - tile.clearViews(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); } lodManager.invalidateRenderedState(); } @@ -235,8 +244,8 @@ public void setHeightOrientation(SurfaceHeightOrientation heightOrientation) { // 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.values()) { - tile.clearViews(); + for (TileRenderState tile : tilesById) { + if (tile != null) tile.clearViews(); } lodManager.invalidateRenderedState(); } @@ -295,8 +304,8 @@ public LodStatistics getLodStatistics() { int lod4 = 0; long triangles = 0L; - for (TileRenderState tile : tilesById.values()) { - if (!tile.isTileVisible()) continue; + for (TileRenderState tile : tilesById) { + if (tile == null || !tile.isTileVisible()) continue; visible++; int lod = tile.getActiveLod(); if (lod == 0) lod0++; @@ -305,7 +314,7 @@ public LodStatistics getLodStatistics() { else if (lod == 3) lod3++; else if (lod >= 4) lod4++; if (lod >= 0 && lod < levels.size()) { - triangles += triangleCountFor(tile, lod); + triangles += triangleCountForTileId(tile.getTileId(), lod); } } @@ -318,58 +327,66 @@ public LodStatistics getLodStatistics() { } private long triangleCountForTileId(int tileId, int lod) { - TileRenderState tile = tilesById.get(tileId); - return tile != null ? triangleCountFor(tile, lod) : 0L; - } - - private long triangleCountFor(TileRenderState tile, int lod) { - if (levels.isEmpty() || lod < 0 || lod >= levels.size()) return 0L; - HeightField field = levels.get(lod); - HeightField l0 = levels.get(0); - - int startX = mapBoundary(tile.startX0, l0.width(), field.width()); - int endX = mapBoundary(tile.endX0, l0.width(), field.width()); - int startZ = mapBoundary(tile.startZ0, l0.height(), field.height()); - int endZ = mapBoundary(tile.endZ0, l0.height(), 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)); - - long cellsX = endX - startX; - long cellsZ = endZ - startZ; - return cellsX * cellsZ * 2L; + 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(); - tilesById.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); - ArrayList specs = new ArrayList<>(columns * rows); int id = 0; for (int row = 0; row < rows; row++) { - int startZ = row * tileCellsL0; - int endZ = Math.min(l0Height, startZ + tileCellsL0); - double minZ = startZ * baseWorldDepth / l0Height; - double maxZ = endZ * baseWorldDepth / l0Height; + 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 startX = column * tileCellsL0; - int endX = Math.min(l0Width, startX + tileCellsL0); - double minX = startX * baseWorldWidth / l0Width; - double maxX = endX * baseWorldWidth / l0Width; + 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.put(id, new TileRenderState( - spec, startX, endX, startZ, endZ, levels.size())); + 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++; } } @@ -389,25 +406,26 @@ private void rebuildTileLayout() { diagnostic("Tiled Hypersurface configured: tiles=" + columns + "x" + rows + " (" + tileSpecs.size() + "), tileCellsL0=" + tileCellsL0 + ", world=" + baseWorldWidth + "x" + baseWorldDepth - + ", persistentLodViews=true"); + + ", persistentLodViews=true, precomputedTileLodInfo=true"); } /** * Visibility/frustum changes are applied immediately. LOD changes are not; * they are queued and budgeted by TiledLodManager. */ - private void applyVisibilityDecisions(List decisions) { + private void applyVisibilityDecisions(boolean[] visibleByTile, int tileStateSize) { int visible = 0; - for (TiledLodManager.TileDecision decision : decisions) { - TileRenderState tile = tilesById.get(decision.tileId()); + 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; - - tile.setTileVisible(decision.visible()); - if (decision.visible()) visible++; + boolean tileVisible = visibleByTile[tileId]; + tile.setTileVisible(tileVisible); + if (tileVisible) visible++; HyperSurfacePlotMesh activeView = tile.getLodView(tile.getActiveLod()); if (activeView != null) { - activeView.setVisible(decision.visible() && isVisible()); + activeView.setVisible(tileVisible && isVisible()); } } visibleTileCount = visible; @@ -419,7 +437,9 @@ private void applyVisibilityDecisions(List decisio */ private TiledLodManager.TransitionResult applyTransition( LodTransition transition, boolean allowBuild) { - TileRenderState tile = tilesById.get(transition.tileId()); + 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)); @@ -453,24 +473,8 @@ private TiledLodManager.TransitionResult applyTransition( private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { HeightField field = levels.get(lod); - HeightField l0 = levels.get(0); - - // Map the constant L0 tile footprint onto the selected pyramid level. - int startX = mapBoundary(tile.startX0, l0.width(), field.width()); - int endX = mapBoundary(tile.endX0, l0.width(), field.width()); - int startZ = mapBoundary(tile.startZ0, l0.height(), field.height()); - int endZ = mapBoundary(tile.endZ0, l0.height(), 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(); - double translateX = startX * scaleX; - double translateZ = startZ * scaleZ; + int tileId = tile.getTileId(); + TileLodInfo info = tileLodInfoByTile[tileId][lod]; HyperSurfacePlotMesh view = new HyperSurfacePlotMesh( 1, 1, 1, 1, yScale, 1.0, p -> 0.0); @@ -478,10 +482,10 @@ private HyperSurfacePlotMesh buildTileLodView(TileRenderState tile, int lod) { view.setCullFace(cullFace); view.setRowOrientation(rowOrientation); view.setHeightOrientation(heightOrientation); - view.updateMeshHeightField(field, startX, startZ, - cellsX, cellsZ, scaleX, yScale, scaleZ); - view.setTranslateX(translateX); - view.setTranslateZ(translateZ); + 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); @@ -506,7 +510,8 @@ private void applySpecular(HyperSurfacePlotMesh view) { } private void forEachBuiltView(java.util.function.Consumer consumer) { - for (TileRenderState tile : tilesById.values()) { + 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);