diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index ab19bb95..7c91be11 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -61,7 +61,7 @@ import edu.jhuapl.trinity.javafx.handlers.SemanticMapEventHandler; import edu.jhuapl.trinity.javafx.handlers.ShapleyEventHandler; import edu.jhuapl.trinity.javafx.javafx3d.Hyperspace3DPane; -import edu.jhuapl.trinity.javafx.javafx3d.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.Hypersurface3DPane; import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; import edu.jhuapl.trinity.javafx.javafx3d.Projections3DPane; import edu.jhuapl.trinity.javafx.javafx3d.ProjectorPane; @@ -183,8 +183,14 @@ public AppAsyncManager(Scene scene, StackPane centerStack, Pane desktopPane, Cir setOnSucceeded(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); - setOnFailed(e -> Platform.runLater(() -> - scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); + setOnFailed(e -> { + Throwable failure = getException(); + if (failure != null) { + LOG.error("Trinity asynchronous initialization failed.", failure); + } + Platform.runLater(() -> + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR))); + }); setOnCancelled(e -> Platform.runLater(() -> scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.HIDE_BUSY_INDICATOR)))); } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java b/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java new file mode 100644 index 00000000..6f2a2213 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/CanvasLineChart.java @@ -0,0 +1,363 @@ +package edu.jhuapl.trinity.javafx.components; + +import javafx.geometry.VPos; +import javafx.scene.canvas.Canvas; +import javafx.scene.canvas.GraphicsContext; +import javafx.scene.control.Label; +import javafx.scene.input.MouseEvent; +import javafx.scene.layout.Pane; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; + +/** + * Lightweight Canvas-backed line chart intended for rapidly changing dense vectors. + * + *
Unlike JavaFX {@code LineChart}, this control does not create a scene-graph node + * for every sample. The complete input vector is rendered directly into a Canvas and + * hover inspection is handled by one overlay Canvas plus one reusable Label.
+ * + * @author Sean Phillips + */ +public final class CanvasLineChart extends Pane { + + private static final double LEFT_MARGIN = 54.0; + private static final double RIGHT_MARGIN = 12.0; + private static final double TOP_MARGIN = 12.0; + private static final double BOTTOM_MARGIN = 28.0; + private static final int GRID_DIVISIONS = 4; + + private final Canvas plotCanvas = new Canvas(); + private final Canvas hoverCanvas = new Canvas(); + private final Label hoverLabel = new Label(); + + private double[] values = new double[0]; + private double dataMinimum = Double.NaN; + private double dataMaximum = Double.NaN; + private int hoverIndex = -1; + private String hoverPrefix = "Vector"; + + public CanvasLineChart() { + setMinSize(0.0, 0.0); + setPrefSize(420.0, 150.0); + + plotCanvas.setManaged(false); + hoverCanvas.setManaged(false); + hoverCanvas.setMouseTransparent(false); + + hoverLabel.setManaged(false); + hoverLabel.setMouseTransparent(true); + hoverLabel.setVisible(false); + hoverLabel.setStyle( + "-fx-font-size: 13;" + + "-fx-font-weight: bold;" + + "-fx-text-fill: aliceblue;" + + "-fx-background-color: rgba(20, 24, 30, 0.86);" + + "-fx-background-radius: 4;" + + "-fx-padding: 4 7 4 7;" + ); + + getChildren().addAll(plotCanvas, hoverCanvas, hoverLabel); + + hoverCanvas.addEventHandler(MouseEvent.MOUSE_MOVED, this::handleMouseMoved); + hoverCanvas.addEventHandler(MouseEvent.MOUSE_EXITED, e -> clearHover()); + } + + public void setHoverPrefix(String hoverPrefix) { + this.hoverPrefix = hoverPrefix == null || hoverPrefix.isBlank() + ? "Vector" + : hoverPrefix; + } + + public void setData(Double[] newData) { + if (newData == null || newData.length == 0) { + values = new double[0]; + dataMinimum = Double.NaN; + dataMaximum = Double.NaN; + clearHover(); + redraw(); + return; + } + + double[] primitive = new double[newData.length]; + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (int i = 0; i < newData.length; i++) { + Double boxed = newData[i]; + double value = boxed == null ? Double.NaN : boxed; + primitive[i] = value; + if (Double.isFinite(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + + values = primitive; + dataMinimum = min == Double.POSITIVE_INFINITY ? Double.NaN : min; + dataMaximum = max == Double.NEGATIVE_INFINITY ? Double.NaN : max; + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + redraw(); + } + + public void setData(double[] newData) { + if (newData == null || newData.length == 0) { + values = new double[0]; + dataMinimum = Double.NaN; + dataMaximum = Double.NaN; + clearHover(); + redraw(); + return; + } + + values = newData.clone(); + double min = Double.POSITIVE_INFINITY; + double max = Double.NEGATIVE_INFINITY; + for (double value : values) { + if (Double.isFinite(value)) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + dataMinimum = min == Double.POSITIVE_INFINITY ? Double.NaN : min; + dataMaximum = max == Double.NEGATIVE_INFINITY ? Double.NaN : max; + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + redraw(); + } + + @Override + protected void layoutChildren() { + double width = Math.max(0.0, getWidth()); + double height = Math.max(0.0, getHeight()); + if (plotCanvas.getWidth() != width) plotCanvas.setWidth(width); + if (plotCanvas.getHeight() != height) plotCanvas.setHeight(height); + if (hoverCanvas.getWidth() != width) hoverCanvas.setWidth(width); + if (hoverCanvas.getHeight() != height) hoverCanvas.setHeight(height); + plotCanvas.relocate(0.0, 0.0); + hoverCanvas.relocate(0.0, 0.0); + redraw(); + } + + private void redraw() { + GraphicsContext g = plotCanvas.getGraphicsContext2D(); + double width = plotCanvas.getWidth(); + double height = plotCanvas.getHeight(); + g.clearRect(0.0, 0.0, width, height); + if (width <= LEFT_MARGIN + RIGHT_MARGIN || height <= TOP_MARGIN + BOTTOM_MARGIN) return; + + double plotX = LEFT_MARGIN; + double plotY = TOP_MARGIN; + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + + drawGridAndAxes(g, plotX, plotY, plotWidth, plotHeight); + if (values.length == 0 || !Double.isFinite(dataMinimum) || !Double.isFinite(dataMaximum)) { + drawCenteredMessage(g, width, height, "No data"); + return; + } + + double yMin = dataMinimum; + double yMax = dataMaximum; + if (Double.compare(yMin, yMax) == 0) { + double padding = Math.max(1.0, Math.abs(yMin) * 0.05); + yMin -= padding; + yMax += padding; + } else { + double padding = (yMax - yMin) * 0.05; + yMin -= padding; + yMax += padding; + } + + drawAxisLabels(g, plotX, plotY, plotWidth, plotHeight, yMin, yMax); + drawSeries(g, plotX, plotY, plotWidth, plotHeight, yMin, yMax); + } + + private void drawGridAndAxes(GraphicsContext g, + double x, + double y, + double width, + double height) { + g.setLineWidth(1.0); + g.setStroke(Color.rgb(180, 190, 200, 0.22)); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + double gy = y + height * fraction; + g.strokeLine(x, gy, x + width, gy); + double gx = x + width * fraction; + g.strokeLine(gx, y, gx, y + height); + } + + g.setStroke(Color.rgb(210, 220, 230, 0.72)); + g.strokeLine(x, y, x, y + height); + g.strokeLine(x, y + height, x + width, y + height); + } + + private void drawAxisLabels(GraphicsContext g, + double x, + double y, + double width, + double height, + double yMin, + double yMax) { + g.setFill(Color.rgb(220, 225, 232, 0.90)); + g.setFont(Font.font(11.0)); + g.setTextBaseline(VPos.CENTER); + + g.setTextAlign(TextAlignment.RIGHT); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + double value = yMax - (yMax - yMin) * fraction; + double py = y + height * fraction; + g.fillText(formatValue(value), x - 6.0, py); + } + + g.setTextBaseline(VPos.TOP); + g.setTextAlign(TextAlignment.CENTER); + int lastIndex = Math.max(0, values.length - 1); + for (int i = 0; i <= GRID_DIVISIONS; i++) { + double fraction = i / (double) GRID_DIVISIONS; + int index = (int) Math.round(lastIndex * fraction); + double px = x + width * fraction; + g.fillText(Integer.toString(index), px, y + height + 6.0); + } + } + + private void drawSeries(GraphicsContext g, + double x, + double y, + double width, + double height, + double yMin, + double yMax) { + double yRange = yMax - yMin; + int lastIndex = Math.max(1, values.length - 1); + + g.setStroke(Color.ALICEBLUE); + g.setLineWidth(1.35); + g.beginPath(); + + boolean pathStarted = false; + for (int i = 0; i < values.length; i++) { + double value = values[i]; + if (!Double.isFinite(value)) { + pathStarted = false; + continue; + } + + double px = x + width * (i / (double) lastIndex); + double py = y + height * ((yMax - value) / yRange); + if (!pathStarted) { + g.moveTo(px, py); + pathStarted = true; + } else { + g.lineTo(px, py); + } + } + g.stroke(); + } + + private void handleMouseMoved(MouseEvent event) { + if (values.length == 0 || !Double.isFinite(dataMinimum) || !Double.isFinite(dataMaximum)) { + clearHover(); + return; + } + + double width = hoverCanvas.getWidth(); + double height = hoverCanvas.getHeight(); + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + if (plotWidth <= 0.0 || plotHeight <= 0.0) return; + + double clampedX = clamp(event.getX(), LEFT_MARGIN, LEFT_MARGIN + plotWidth); + double fraction = (clampedX - LEFT_MARGIN) / plotWidth; + int index = (int) Math.round(fraction * Math.max(0, values.length - 1)); + index = Math.max(0, Math.min(values.length - 1, index)); + double value = values[index]; + if (!Double.isFinite(value)) { + clearHover(); + return; + } + + hoverIndex = index; + drawHover(index, value); + hoverLabel.setText(hoverPrefix + "(" + index + ") = " + formatValue(value)); + hoverLabel.applyCss(); + double labelWidth = hoverLabel.prefWidth(-1.0); + double labelHeight = hoverLabel.prefHeight(-1.0); + double labelX = clamp(event.getX() + 10.0, 0.0, Math.max(0.0, width - labelWidth)); + double labelY = clamp(event.getY() - labelHeight - 10.0, 0.0, Math.max(0.0, height - labelHeight)); + hoverLabel.resizeRelocate(labelX, labelY, labelWidth, labelHeight); + hoverLabel.setVisible(true); + } + + private void drawHover(int index, double value) { + clearHoverCanvas(); + + double width = hoverCanvas.getWidth(); + double height = hoverCanvas.getHeight(); + double plotWidth = width - LEFT_MARGIN - RIGHT_MARGIN; + double plotHeight = height - TOP_MARGIN - BOTTOM_MARGIN; + + double yMin = dataMinimum; + double yMax = dataMaximum; + if (Double.compare(yMin, yMax) == 0) { + double padding = Math.max(1.0, Math.abs(yMin) * 0.05); + yMin -= padding; + yMax += padding; + } else { + double padding = (yMax - yMin) * 0.05; + yMin -= padding; + yMax += padding; + } + + int lastIndex = Math.max(1, values.length - 1); + double px = LEFT_MARGIN + plotWidth * (index / (double) lastIndex); + double py = TOP_MARGIN + plotHeight * ((yMax - value) / (yMax - yMin)); + + GraphicsContext g = hoverCanvas.getGraphicsContext2D(); + g.setStroke(Color.rgb(240, 248, 255, 0.50)); + g.setLineWidth(1.0); + g.strokeLine(px, TOP_MARGIN, px, TOP_MARGIN + plotHeight); + g.setFill(Color.ALICEBLUE); + g.fillOval(px - 3.0, py - 3.0, 6.0, 6.0); + } + + private void clearHover() { + hoverIndex = -1; + hoverLabel.setVisible(false); + clearHoverCanvas(); + } + + private void clearHoverCanvas() { + GraphicsContext g = hoverCanvas.getGraphicsContext2D(); + g.clearRect(0.0, 0.0, hoverCanvas.getWidth(), hoverCanvas.getHeight()); + } + + private static void drawCenteredMessage(GraphicsContext g, + double width, + double height, + String message) { + g.setFill(Color.rgb(220, 225, 232, 0.80)); + g.setFont(Font.font(12.0)); + g.setTextAlign(TextAlignment.CENTER); + g.setTextBaseline(VPos.CENTER); + g.fillText(message, width / 2.0, height / 2.0); + } + + private static String formatValue(double value) { + double abs = Math.abs(value); + if ((abs > 0.0 && abs < 0.001) || abs >= 100000.0) { + return String.format("%.3e", value); + } + if (abs >= 1000.0) return String.format("%.1f", value); + if (abs >= 10.0) return String.format("%.2f", value); + return String.format("%.4f", value); + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java b/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java index d776e8f1..aebe3090 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/FactorControlBox.java @@ -2,18 +2,14 @@ import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; import javafx.collections.FXCollections; -import javafx.collections.ObservableList; import javafx.geometry.Insets; -import javafx.scene.chart.LineChart; -import javafx.scene.chart.NumberAxis; -import javafx.scene.chart.XYChart; import javafx.scene.control.ChoiceBox; import javafx.scene.control.Label; -import javafx.scene.control.Tooltip; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; @@ -27,34 +23,27 @@ public class FactorControlBox extends VBox { Background transFillBack = new Background(new BackgroundFill( Color.ALICEBLUE.deriveColor(1, 1, 1, 0.222), CornerRadii.EMPTY, Insets.EMPTY)); - public ObservableListThe controller deliberately operates on world-space surface bounds rather than + * source or render resolution. This keeps camera framing stable across LOD changes, + * tiled rendering, and large image sources. Rendering/LOD policy remains outside the + * controller and is notified through narrow interaction callbacks.
+ */ +public final class HypersurfaceCameraController { + + private static final Logger LOG = LoggerFactory.getLogger(HypersurfaceCameraController.class); + + public enum InteractionType { + DRAG, + ZOOM, + KEYBOARD + } + + public enum Preset { + OBLIQUE(-10.0, -45.0, 0.0), + TOP(-90.0, 0.0, 0.0), + FRONT(0.0, 0.0, 0.0), + SIDE(0.0, -90.0, 0.0); + + private final double rotateX; + private final double rotateY; + private final double rotateZ; + + Preset(double rotateX, double rotateY, double rotateZ) { + this.rotateX = rotateX; + this.rotateY = rotateY; + this.rotateZ = rotateZ; + } + + double rotateX() { + return rotateX; + } + + double rotateY() { + return rotateY; + } + + double rotateZ() { + return rotateZ; + } + } + + /** + * World-space extents centered around the Hypersurface origin. + */ + public record SurfaceBounds(double width, double height, double depth) { + public SurfaceBounds { + width = sanitizeExtent(width); + height = sanitizeExtent(height); + depth = sanitizeExtent(depth); + } + + private static double sanitizeExtent(double value) { + return Double.isFinite(value) && value > 0.0 ? value : 1.0; + } + } + + @FunctionalInterface + public interface BoundsProvider { + SurfaceBounds getSurfaceBounds(); + } + + private static final double DEFAULT_FIT_PADDING = 1.08; + private static final double MIN_HALF_FOV_RADIANS = Math.toRadians(1.0); + private static final double MIN_CAMERA_DISTANCE = 10.0; + private static final double GESTURE_ZOOM_STEP = 50.0; + private static final double MOUSE_ROTATION_FACTOR = 0.1; + private static final double MOUSE_PAN_FACTOR = 0.3; + + private final PerspectiveCamera camera; + private final CameraTransformer cameraTransform; + private final SubScene subScene; + private final BoundsProvider boundsProvider; + private final ConsumerThe graph was originally embedded in Hypersurface as an experiment. This + * controller deliberately avoids depending on {@link Hypersurface3DPane} so the + * graph rendering/style logic can later migrate to a dedicated 3D graph pane and + * SubScene with minimal restructuring. The optional graph consumer is the temporary + * bridge used to keep Hypersurface-specific graph-to-surface interaction working.
+ * + * @author Sean Phillips + */ +public final class HypersurfaceGraphController { + + private final Scene scene; + private final ConsumerThe controller resolves picked surface positions into source/render indices, + * dispatches hover-derived analysis events, coordinates crosshair requests, publishes + * FeatureVector selections for Trinity's shared inspection tools, and interprets graph + * hover/click events that map back onto surface rows.
+ * + * @author Sean Phillips + */ +public final class HypersurfaceInteractionController { + + /** + * Resolved semantic meaning of a picked surface position. Render row/column refer + * to the currently active HeightField while source row/column remain stable across + * LOD changes. + */ + public record SurfaceInteractionContext( + Point3D surfacePoint, + int renderRow, + int renderColumn, + int sourceRow, + int sourceColumn, + double value + ) { + } + + private final Hypersurface3DPane pane; + private final Scene scene; + private final SurfaceCoordinateMapper coordinateMapper; + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay; + private final HypersurfaceOverlayManager overlayManager; + + private boolean hoverEnabled; + private boolean surfaceChartsEnabled; + private boolean crosshairsEnabled; + + private FeatureVector lastPublishedFeatureVector; + private SurfaceInteractionContext lastInteractionContext; + private SurfaceInspection lastSurfaceInspection; + + private int lastStatsRenderRow = -1; + private int lastStatsRenderColumn = -1; + private double lastRowMinimum = Double.NaN; + private double lastRowMaximum = Double.NaN; + private double lastColumnMinimum = Double.NaN; + private double lastColumnMaximum = Double.NaN; + + private GraphDirectedCollection currentGraph; + private final IdentityHashMapThis controller deliberately contains no scene-graph, renderer, control, + * or LOD scheduling behavior. It transforms source data owned by + * {@link HypersurfaceSourceModel}, builds the processed HeightField pyramid, + * and provides interpolation/sampling behavior used by the legacy mesh path.
+ * + * @author Sean Phillips + */ +public final class HypersurfaceProcessingController { + + private static final int DEFAULT_MAX_RENDER_RESOLUTION = 2048; + private static final int DEFAULT_MIN_RENDER_RESOLUTION = 512; + private static final int TILED_LOD_LEVEL_COUNT = 5; + + private final HypersurfaceSourceModel sourceModel; + + private HeightMode heightMode = HeightMode.RAW; + private boolean smoothingEnabled; + private SurfaceUtils.Smoothing smoothingMethod = SurfaceUtils.Smoothing.GAUSSIAN; + private int smoothingRadius = 2; + private double gaussianSigma = 1.0; + private SurfaceUtils.Interpolation interpolationMode = SurfaceUtils.Interpolation.NEAREST; + private boolean toneEnabled; + private SurfaceUtils.ToneMap toneOperator = SurfaceUtils.ToneMap.NONE; + private double toneParam = 2.0; + + private int maxRenderResolution = DEFAULT_MAX_RENDER_RESOLUTION; + private int minRenderResolution = DEFAULT_MIN_RENDER_RESOLUTION; + + public HypersurfaceProcessingController(HypersurfaceSourceModel sourceModel) { + if (sourceModel == null) { + throw new IllegalArgumentException("sourceModel cannot be null"); + } + this.sourceModel = sourceModel; + } + + public void setHeightMode(HeightMode heightMode) { + this.heightMode = heightMode != null ? heightMode : HeightMode.RAW; + } + + public void setSmoothingEnabled(boolean smoothingEnabled) { + this.smoothingEnabled = smoothingEnabled; + } + + public void setSmoothingMethod(SurfaceUtils.Smoothing smoothingMethod) { + this.smoothingMethod = smoothingMethod != null + ? smoothingMethod + : SurfaceUtils.Smoothing.GAUSSIAN; + } + + public void setSmoothingRadius(int smoothingRadius) { + this.smoothingRadius = smoothingRadius; + } + + public void setGaussianSigma(double gaussianSigma) { + this.gaussianSigma = gaussianSigma; + } + + public void setInterpolationMode(SurfaceUtils.Interpolation interpolationMode) { + this.interpolationMode = interpolationMode != null + ? interpolationMode + : SurfaceUtils.Interpolation.NEAREST; + } + + public void setToneEnabled(boolean toneEnabled) { + this.toneEnabled = toneEnabled; + } + + public void setToneOperator(SurfaceUtils.ToneMap toneOperator) { + this.toneOperator = toneOperator != null + ? toneOperator + : SurfaceUtils.ToneMap.NONE; + } + + public void setToneParam(double toneParam) { + this.toneParam = toneParam; + } + + public int getMaxRenderResolution() { + return maxRenderResolution; + } + + public boolean setMaxRenderResolution(int maxRenderResolution) { + if (maxRenderResolution <= 0) { + throw new IllegalArgumentException("maxRenderResolution must be > 0"); + } + if (maxRenderResolution < minRenderResolution) { + throw new IllegalArgumentException("maxRenderResolution must be >= minRenderResolution"); + } + if (this.maxRenderResolution == maxRenderResolution) return false; + this.maxRenderResolution = maxRenderResolution; + return true; + } + + public int getMinRenderResolution() { + return minRenderResolution; + } + + public boolean setMinRenderResolution(int minRenderResolution) { + if (minRenderResolution <= 0) { + throw new IllegalArgumentException("minRenderResolution must be > 0"); + } + if (minRenderResolution > maxRenderResolution) { + throw new IllegalArgumentException("minRenderResolution must be <= maxRenderResolution"); + } + if (this.minRenderResolution == minRenderResolution) return false; + this.minRenderResolution = minRenderResolution; + return true; + } + + public ListThe source model is updated atomically from the perspective of callers: + * a new pyramid replaces the previous one, active LOD state is invalidated, + * and stable world extents are recomputed. Renderer selection and LOD + * scheduling remain the responsibility of Hypersurface3DPane.
+ * + * @return true when a processed pyramid was produced + */ + public boolean rebuildProcessedPyramid(int fallbackWidth, + int fallbackHeight, + double surfaceScale) { + if (sourceModel.getProcessingSourceHeightField() == null) { + if (sourceModel.getOriginalGrid() == null || sourceModel.getOriginalGrid().isEmpty()) { + return false; + } + sourceModel.setProcessingSourceHeightField( + SurfaceUtils.toHeightField(sourceModel.getOriginalGrid())); + } + + HeightField l0Raw = sourceModel.isImageBackedSurface() + ? sourceModel.getProcessingSourceHeightField() + : SurfaceUtils.buildL0Raw( + sourceModel.getProcessingSourceHeightField(), maxRenderResolution); + + SurfaceUtils.Smoothing smoothing = smoothingEnabled + ? smoothingMethod + : SurfaceUtils.Smoothing.NONE; + SurfaceUtils.ToneMap toneMap = toneEnabled + ? toneOperator + : SurfaceUtils.ToneMap.NONE; + + HeightField l0Processed = SurfaceUtils.processL0( + l0Raw, + smoothing, + smoothingRadius, + 1, + gaussianSigma, + toneMap, + toneParam); + + sourceModel.setLodProcessedLevels(sourceModel.isImageBackedSurface() + ? SurfaceUtils.buildPyramidLevels(l0Processed, TILED_LOD_LEVEL_COUNT) + : SurfaceUtils.buildPyramid(l0Processed, minRenderResolution)); + + sourceModel.recomputeBaseWorldExtents( + fallbackWidth, fallbackHeight, surfaceScale); + sourceModel.setActiveLod(-1, null); + sourceModel.clearCurrentLodSurfScale(); + return true; + } + + public String buildPyramidSummary() { + StringBuilder summary = new StringBuilder("Hypersurface tiled LOD pyramid: "); + ListThe controller deliberately does not process source data, interpret user + * interaction, manage overlays, or own application controls. It consumes processed + * HeightFields from {@link HypersurfaceSourceModel} and coordinates the legacy/global + * and tiled rendering paths.
+ * + * @author Sean Phillips + */ +public final class HypersurfaceRenderController { + + private static final int DEFAULT_TILE_CELLS_L0 = 256; + private static final int TOTAL_COLORS = 1530; + + private final Hypersurface3DPane owner; + private final HypersurfaceSourceModel sourceModel; + private final HypersurfaceProcessingController processingController; + private final SurfaceCrosshairOverlay surfaceCrosshairOverlay; + private final Runnable coordinateMapperRefresh; + + private final FunctionThis model deliberately contains no renderer, scene-graph, control, or LOD + * scheduling behavior. It tracks the source collections/image region, primitive + * HeightField state derived from that source, source/render dimensions, stable + * world extents, and the row/height orientation semantics shared by renderers and + * interaction code.
+ * + * @author Sean Phillips + */ +public final class HypersurfaceSourceModel { + + private final ListThe mapper deliberately keeps source dimensions independent from render + * dimensions. This matters for image-backed surfaces where an 8K source may be + * rendered from a capped 2K L0 HeightField while preserving the original source + * row/column semantics.
+ * + * @author Sean Phillips + */ +public final class SurfaceCoordinateMapper { + + private int sourceWidth = 1; + private int sourceHeight = 1; + private int renderWidth = 1; + private int renderHeight = 1; + private double worldWidth = 1.0; + private double worldDepth = 1.0; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + + public void configure(int sourceWidth, + int sourceHeight, + int renderWidth, + int renderHeight, + double worldWidth, + double worldDepth, + SurfaceRowOrientation rowOrientation) { + this.sourceWidth = Math.max(1, sourceWidth); + this.sourceHeight = Math.max(1, sourceHeight); + this.renderWidth = Math.max(1, renderWidth); + this.renderHeight = Math.max(1, renderHeight); + this.worldWidth = sanitizeExtent(worldWidth); + this.worldDepth = sanitizeExtent(worldDepth); + this.rowOrientation = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + } + + public double sourceColumnToWorldX(int sourceColumn) { + return sourceColumnToSurfaceX(sourceColumn) - worldWidth / 2.0; + } + + public int worldXToSourceColumn(double worldX) { + return surfaceXToSourceColumn(worldX + worldWidth / 2.0); + } + + public double sourceRowToWorldZ(int sourceRow) { + return sourceRowToSurfaceZ(sourceRow) - worldDepth / 2.0; + } + + public int worldZToSourceRow(double worldZ) { + return surfaceZToSourceRow(worldZ + worldDepth / 2.0); + } + + /** + * Converts a source column to the uncentered surface-local X coordinate used + * by HyperSurfacePlotMesh and TiledSurfaceRenderer before their root translation. + */ + public double sourceColumnToSurfaceX(int sourceColumn) { + int clamped = clampIndex(sourceColumn, sourceWidth); + return clamped * sourceCellWidth(); + } + + public int surfaceXToSourceColumn(double surfaceX) { + int visualColumn = coordinateToIndex(surfaceX, worldWidth, sourceWidth); + return clampIndex(visualColumn, sourceWidth); + } + + /** + * Converts a source row to uncentered surface-local Z. Row orientation is + * applied here, so source row zero maps to either the near or far edge. + */ + public double sourceRowToSurfaceZ(int sourceRow) { + int clamped = clampIndex(sourceRow, sourceHeight); + int visualRow = orientSourceRowToVisualRow(clamped); + return visualRow * sourceCellDepth(); + } + + public int surfaceZToSourceRow(double surfaceZ) { + int visualRow = coordinateToIndex(surfaceZ, worldDepth, sourceHeight); + return orientVisualRowToSourceRow(visualRow); + } + + /** Render-grid indices are visual indices and therefore are not row-flipped. */ + public int surfaceXToRenderColumn(double surfaceX) { + return coordinateToIndex(surfaceX, worldWidth, renderWidth); + } + + /** Render-grid indices are visual indices and therefore are not row-flipped. */ + public int surfaceZToRenderRow(double surfaceZ) { + return coordinateToIndex(surfaceZ, worldDepth, renderHeight); + } + + public int renderColumnToSourceColumn(int renderColumn) { + return mapIndex(clampIndex(renderColumn, renderWidth), renderWidth, sourceWidth); + } + + public int renderRowToSourceRow(int renderRow) { + int visualRenderRow = clampIndex(renderRow, renderHeight); + int visualSourceRow = mapIndex(visualRenderRow, renderHeight, sourceHeight); + return orientVisualRowToSourceRow(visualSourceRow); + } + + /** + * Maps an L0/render-grid column boundary in [0, renderWidth] to the + * corresponding source-grid boundary in [0, sourceWidth]. Shared tile + * boundaries therefore remain contiguous after source scaling. + */ + public int renderColumnBoundaryToSourceBoundary(int renderBoundary) { + int clamped = Math.max(0, Math.min(renderBoundary, renderWidth)); + return (int) Math.round(clamped * sourceWidth / (double) renderWidth); + } + + /** + * Maps an L0/render-grid visual row boundary to the corresponding source-row + * boundary. FIRST_ROW_FAR reverses the boundary direction so consumers can + * use the returned values directly in normal top-to-bottom source-image space. + */ + public int renderRowBoundaryToSourceBoundary(int renderBoundary) { + int clamped = Math.max(0, Math.min(renderBoundary, renderHeight)); + int visualSourceBoundary = (int) Math.round( + clamped * sourceHeight / (double) renderHeight); + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - visualSourceBoundary + : visualSourceBoundary; + } + + public int sourceWidth() { + return sourceWidth; + } + + public int sourceHeight() { + return sourceHeight; + } + + public int renderWidth() { + return renderWidth; + } + + public int renderHeight() { + return renderHeight; + } + + public double worldWidth() { + return worldWidth; + } + + public double worldDepth() { + return worldDepth; + } + + private double sourceCellWidth() { + return worldWidth / sourceWidth; + } + + private double sourceCellDepth() { + return worldDepth / sourceHeight; + } + + private int orientSourceRowToVisualRow(int sourceRow) { + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - 1 - sourceRow + : sourceRow; + } + + private int orientVisualRowToSourceRow(int visualRow) { + int clamped = clampIndex(visualRow, sourceHeight); + return rowOrientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? sourceHeight - 1 - clamped + : clamped; + } + + private static int coordinateToIndex(double coordinate, double extent, int size) { + if (size <= 1 || !(extent > 0.0) || !Double.isFinite(coordinate)) return 0; + double clamped = Math.max(0.0, Math.min(coordinate, Math.nextDown(extent))); + int index = (int) Math.floor(clamped * size / extent); + return clampIndex(index, size); + } + + private static int mapIndex(int index, int fromSize, int toSize) { + if (toSize <= 1 || fromSize <= 1) return 0; + double targetCoordinate = ((index + 0.5) * toSize / (double) fromSize) - 0.5; + return clampIndex((int) Math.round(targetCoordinate), toSize); + } + + private static int clampIndex(int index, int size) { + return Math.max(0, Math.min(index, Math.max(1, size) - 1)); + } + + private static double sanitizeExtent(double extent) { + return Double.isFinite(extent) && extent > 0.0 ? extent : 1.0; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java new file mode 100644 index 00000000..188fbca5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceCrosshairOverlay.java @@ -0,0 +1,424 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +import javafx.animation.AnimationTimer; +import javafx.scene.Group; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.MeshView; +import javafx.scene.shape.TriangleMesh; + +/** + * Lightweight reusable crosshair overlay for Hypersurface height fields. + * + *The overlay owns the full crosshair rendering lifecycle: active surface + * context, pending target, fixed-rate JavaFX pulse coalescing, dirty-state + * tracking, and the two reusable surface-following ribbon meshes. Callers + * provide semantic position requests; rapid requests are collapsed to the + * newest target and mesh updates are capped at 30 Hz.
+ * + *Row and column ribbons are tracked independently. Moving only the row, + * which is the common graph-hover case, updates only the row mesh point buffer + * while the unchanged column mesh is left untouched.
+ * + * @author Sean Phillips + */ +public final class SurfaceCrosshairOverlay extends Group { + + private static final double MIN_WORLD_THICKNESS = 0.35; + private static final double THICKNESS_CELL_FRACTION = 0.35; + private static final double MIN_LIFT = 0.20; + + /** + * Maximum crosshair mesh-update rate. Mouse/graph position requests may + * arrive much faster; only the newest pending target is consumed when this + * interval has elapsed. This bounds TriangleMesh point-buffer churn while + * retaining latest-position-wins behavior. + */ + private static final int CROSSHAIR_UPDATE_HZ = 30; + private static final long CROSSHAIR_UPDATE_INTERVAL_NANOS = + 1_000_000_000L / CROSSHAIR_UPDATE_HZ; + + private enum RequestSpace { + NONE, + RENDER, + SOURCE + } + + private final TriangleMesh rowMesh = new TriangleMesh(); + private final TriangleMesh columnMesh = new TriangleMesh(); + private final MeshView rowView = new MeshView(rowMesh); + private final MeshView columnView = new MeshView(columnMesh); + private final PhongMaterial material = new PhongMaterial(Color.WHITE); + + private final AnimationTimer updateTimer = new AnimationTimer() { + @Override + public void handle(long now) { + if (!updatePending) { + stop(); + return; + } + + if (lastUpdateNanos != 0L + && now - lastUpdateNanos < CROSSHAIR_UPDATE_INTERVAL_NANOS) { + return; + } + + lastUpdateNanos = now; + applyPendingUpdate(); + + if (!updatePending) stop(); + } + }; + + private HeightField heightField; + private SurfaceCoordinateMapper coordinateMapper; + private double worldWidth = 1.0; + private double worldDepth = 1.0; + private double yScale = 1.0; + private SurfaceRowOrientation rowOrientation = SurfaceRowOrientation.FIRST_ROW_NEAR; + private SurfaceHeightOrientation heightOrientation = SurfaceHeightOrientation.HIGH_VALUES_UP; + + private int topologyWidth = -1; + private int topologyHeight = -1; + private float[] rowPoints = new float[0]; + private float[] columnPoints = new float[0]; + + private RequestSpace requestSpace = RequestSpace.NONE; + private int requestedRow = -1; + private int requestedColumn = -1; + private boolean updatePending = false; + private long lastUpdateNanos = 0L; + + private int renderedVisualRow = -1; + private int renderedVisualColumn = -1; + private boolean rowGeometryDirty = true; + private boolean columnGeometryDirty = true; + + public SurfaceCrosshairOverlay() { + material.setDiffuseColor(Color.WHITE); + material.setSpecularColor(Color.TRANSPARENT); + + configureView(rowView); + configureView(columnView); + getChildren().addAll(rowView, columnView); + setMouseTransparent(true); + setVisible(false); + } + + private void configureView(MeshView view) { + view.setMaterial(material); + view.setCullFace(CullFace.NONE); + view.setMouseTransparent(true); + } + + public void setColor(Color color) { + Color resolved = color != null ? color : Color.WHITE; + material.setDiffuseColor(resolved); + } + + /** + * Configures the surface context used by future crosshair requests. + * + *If the geometry context changes while a crosshair request is active, + * the current semantic request is reapplied on the next permitted crosshair update. This + * keeps source-space requests correct across orientation, scale, and LOD + * changes without exposing that bookkeeping to Hypersurface3DPane.
+ */ + public void configureSurface(HeightField heightField, + SurfaceCoordinateMapper coordinateMapper, + double yScale, + SurfaceRowOrientation rowOrientation, + SurfaceHeightOrientation heightOrientation) { + if (heightField == null || coordinateMapper == null + || heightField.width() <= 0 || heightField.height() <= 0) { + clearSurface(); + return; + } + + double newWorldWidth = coordinateMapper.worldWidth(); + double newWorldDepth = coordinateMapper.worldDepth(); + SurfaceRowOrientation newRowOrientation = rowOrientation != null + ? rowOrientation + : SurfaceRowOrientation.FIRST_ROW_NEAR; + SurfaceHeightOrientation newHeightOrientation = heightOrientation != null + ? heightOrientation + : SurfaceHeightOrientation.HIGH_VALUES_UP; + + boolean geometryChanged = this.heightField != heightField + || this.coordinateMapper != coordinateMapper + || Double.compare(this.worldWidth, newWorldWidth) != 0 + || Double.compare(this.worldDepth, newWorldDepth) != 0 + || Double.compare(this.yScale, yScale) != 0 + || this.rowOrientation != newRowOrientation + || this.heightOrientation != newHeightOrientation; + + this.heightField = heightField; + this.coordinateMapper = coordinateMapper; + this.worldWidth = sanitizeExtent(newWorldWidth); + this.worldDepth = sanitizeExtent(newWorldDepth); + this.yScale = Double.isFinite(yScale) ? yScale : 1.0; + this.rowOrientation = newRowOrientation; + this.heightOrientation = newHeightOrientation; + + if (geometryChanged) { + rowGeometryDirty = true; + columnGeometryDirty = true; + if (requestSpace != RequestSpace.NONE) scheduleUpdate(); + } + } + + /** + * Requests a crosshair using visual render-grid indices. + * Intermediate requests are coalesced to the newest target. Mesh updates + * are rate-limited by {@link #CROSSHAIR_UPDATE_HZ}. + */ + public void requestRenderPosition(int visualRow, int visualColumn) { + if (!hasSurface()) { + hide(); + return; + } + requestSpace = RequestSpace.RENDER; + requestedRow = visualRow; + requestedColumn = visualColumn; + scheduleUpdate(); + } + + /** + * Requests a crosshair using source-data row/column coordinates. + */ + public void requestSourcePosition(int sourceRow, int sourceColumn) { + if (!hasSurface()) { + hide(); + return; + } + requestSpace = RequestSpace.SOURCE; + requestedRow = sourceRow; + requestedColumn = sourceColumn; + scheduleUpdate(); + } + + /** + * Requests a source row with the perpendicular crosshair at the source center. + * This is the graph-node highlighting path. + */ + public void requestSourceRow(int sourceRow) { + if (!hasSurface()) { + hide(); + return; + } + requestSourcePosition(sourceRow, coordinateMapper.sourceWidth() / 2); + } + + /** + * Cancels any pending update and hides the overlay. Surface configuration is + * retained so a later position request can be serviced without reconfiguration. + */ + public void hide() { + updatePending = false; + requestSpace = RequestSpace.NONE; + requestedRow = -1; + requestedColumn = -1; + updateTimer.stop(); + lastUpdateNanos = 0L; + setVisible(false); + } + + /** + * Releases the active surface context and invalidates rendered geometry. + */ + public void clearSurface() { + hide(); + heightField = null; + coordinateMapper = null; + renderedVisualRow = -1; + renderedVisualColumn = -1; + rowGeometryDirty = true; + columnGeometryDirty = true; + } + + private void scheduleUpdate() { + updatePending = true; + updateTimer.start(); + } + + private void applyPendingUpdate() { + if (!updatePending) return; + updatePending = false; + + if (!hasSurface() || requestSpace == RequestSpace.NONE) { + setVisible(false); + return; + } + + int visualRow; + int visualColumn; + if (requestSpace == RequestSpace.SOURCE) { + double surfaceZ = coordinateMapper.sourceRowToSurfaceZ(requestedRow); + double surfaceX = coordinateMapper.sourceColumnToSurfaceX(requestedColumn); + visualRow = coordinateMapper.surfaceZToRenderRow(surfaceZ); + visualColumn = coordinateMapper.surfaceXToRenderColumn(surfaceX); + } else { + visualRow = requestedRow; + visualColumn = requestedColumn; + } + + final int width = heightField.width(); + final int height = heightField.height(); + ensureTopology(width, height); + + final int row = clamp(visualRow, height); + final int column = clamp(visualColumn, width); + final double scaleX = worldWidth / width; + final double scaleZ = worldDepth / height; + final double halfRowThickness = Math.max( + MIN_WORLD_THICKNESS, + scaleZ * THICKNESS_CELL_FRACTION) * 0.5; + final double halfColumnThickness = Math.max( + MIN_WORLD_THICKNESS, + scaleX * THICKNESS_CELL_FRACTION) * 0.5; + final double lift = Math.max(MIN_LIFT, Math.abs(yScale) * 0.02); + + if (rowGeometryDirty || row != renderedVisualRow) { + updateRowPoints(heightField, row, scaleX, scaleZ, + halfRowThickness, lift); + rowMesh.getPoints().set(0, rowPoints, 0, rowPoints.length); + renderedVisualRow = row; + rowGeometryDirty = false; + } + + if (columnGeometryDirty || column != renderedVisualColumn) { + updateColumnPoints(heightField, column, scaleX, scaleZ, + halfColumnThickness, lift); + columnMesh.getPoints().set(0, columnPoints, 0, columnPoints.length); + renderedVisualColumn = column; + columnGeometryDirty = false; + } + + setVisible(true); + } + + private void updateRowPoints(HeightField heightField, + int visualRow, + double scaleX, + double scaleZ, + double halfThickness, + double lift) { + final int width = heightField.width(); + final int sourceRow = orientVisualRow(visualRow, heightField.height(), rowOrientation); + final int rowOffset = sourceRow * width; + final float[] data = heightField.data(); + final double zCenter = visualRow * scaleZ - worldDepth / 2.0; + + int out = 0; + for (int vx = 0; vx <= width; vx++) { + int sampleX = Math.min(vx, width - 1); + double x = vx * scaleX - worldWidth / 2.0; + double y = heightOrientation.toWorldY(data[rowOffset + sampleX], yScale) - lift; + + rowPoints[out++] = (float) x; + rowPoints[out++] = (float) y; + rowPoints[out++] = (float) (zCenter - halfThickness); + + rowPoints[out++] = (float) x; + rowPoints[out++] = (float) y; + rowPoints[out++] = (float) (zCenter + halfThickness); + } + } + + private void updateColumnPoints(HeightField heightField, + int visualColumn, + double scaleX, + double scaleZ, + double halfThickness, + double lift) { + final int width = heightField.width(); + final int height = heightField.height(); + final float[] data = heightField.data(); + final double xCenter = visualColumn * scaleX - worldWidth / 2.0; + + int out = 0; + for (int vz = 0; vz <= height; vz++) { + int visualSampleRow = Math.min(vz, height - 1); + int sourceRow = orientVisualRow(visualSampleRow, height, rowOrientation); + double z = vz * scaleZ - worldDepth / 2.0; + double y = heightOrientation.toWorldY( + data[sourceRow * width + visualColumn], yScale) - lift; + + columnPoints[out++] = (float) (xCenter - halfThickness); + columnPoints[out++] = (float) y; + columnPoints[out++] = (float) z; + + columnPoints[out++] = (float) (xCenter + halfThickness); + columnPoints[out++] = (float) y; + columnPoints[out++] = (float) z; + } + } + + private void ensureTopology(int width, int height) { + if (width == topologyWidth && height == topologyHeight) return; + + topologyWidth = width; + topologyHeight = height; + rowPoints = new float[(width + 1) * 2 * 3]; + columnPoints = new float[(height + 1) * 2 * 3]; + + rowMesh.getPoints().setAll(rowPoints); + columnMesh.getPoints().setAll(columnPoints); + rowMesh.getTexCoords().setAll(0.0f, 0.0f); + columnMesh.getTexCoords().setAll(0.0f, 0.0f); + rowMesh.getFaces().setAll(buildRibbonFaces(width)); + columnMesh.getFaces().setAll(buildRibbonFaces(height)); + + renderedVisualRow = -1; + renderedVisualColumn = -1; + rowGeometryDirty = true; + columnGeometryDirty = true; + } + + private boolean hasSurface() { + return heightField != null + && coordinateMapper != null + && heightField.width() > 0 + && heightField.height() > 0 + && worldWidth > 0.0 + && worldDepth > 0.0; + } + + private static int[] buildRibbonFaces(int segments) { + int[] faces = new int[segments * 2 * 6]; + int out = 0; + for (int i = 0; i < segments; i++) { + int a = i * 2; + int b = a + 1; + int c = a + 2; + int d = a + 3; + + faces[out++] = a; faces[out++] = 0; + faces[out++] = c; faces[out++] = 0; + faces[out++] = d; faces[out++] = 0; + + faces[out++] = d; faces[out++] = 0; + faces[out++] = b; faces[out++] = 0; + faces[out++] = a; faces[out++] = 0; + } + return faces; + } + + private static int orientVisualRow(int visualRow, + int height, + SurfaceRowOrientation orientation) { + int clamped = clamp(visualRow, height); + return orientation == SurfaceRowOrientation.FIRST_ROW_FAR + ? height - 1 - clamped + : clamped; + } + + private static int clamp(int value, int size) { + return Math.max(0, Math.min(value, Math.max(1, size) - 1)); + } + + private static double sanitizeExtent(double extent) { + return Double.isFinite(extent) && extent > 0.0 ? extent : 1.0; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java similarity index 96% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java index 58cab0cd..57e91695 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceHeightOrientation.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceHeightOrientation.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Controls how scalar surface height values map onto the JavaFX Y axis. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java new file mode 100644 index 00000000..48aacda4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceInspection.java @@ -0,0 +1,52 @@ +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; + +/** + * Presentation-ready inspection state for a hovered Hypersurface location. + * + *All source/image coordinates are resolved before publication so consumers such + * as Content Navigator do not need to understand LOD, row orientation, or tile + * layout. Tile maximum coordinates are exclusive bounds.
+ * + * @author Sean Phillips + */ +public record SurfaceInspection( + boolean imageBacked, + int sourceWidth, + int sourceHeight, + int renderWidth, + int renderHeight, + int renderColumn, + int renderRow, + int sourceColumn, + int sourceRow, + int imageColumn, + int imageRow, + double surfaceValue, + double worldX, + double worldY, + double worldZ, + double rowMinimum, + double rowMaximum, + double columnMinimum, + double columnMaximum, + int red, + int green, + int blue, + double imageIntensity, + int tileId, + int activeLod, + int tileImageMinX, + int tileImageMinY, + int tileImageMaxXExclusive, + int tileImageMaxYExclusive +) { + public boolean hasImageSample() { + return imageBacked && red >= 0 && green >= 0 && blue >= 0; + } + + public boolean hasTile() { + return tileId >= 0 + && tileImageMaxXExclusive > tileImageMinX + && tileImageMaxYExclusive > tileImageMinY; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java similarity index 94% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java index a9275df1..dee022cb 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceRowOrientation.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceRowOrientation.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Controls how source rows map onto the Hypersurface Z axis. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java index 9e652e87..ca614c31 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/SurfaceUtils.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import java.util.ArrayList; import java.util.Collections; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java similarity index 97% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java index 6e012c2b..79383ed5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TileRenderState.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TileRenderState.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; /** * Persistent JavaFX render state for one tiled hypersurface region. diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java index 655df7f9..7d06a596 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledLodManager.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledLodManager.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.animation.AnimationTimer; import javafx.animation.PauseTransition; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java similarity index 92% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java index c0eca711..3b1d8ac1 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/TiledSurfaceRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/TiledSurfaceRenderer.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hypersurface; import javafx.scene.Group; import javafx.scene.PerspectiveCamera; @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * Tiled renderer for primitive HeightField LOD pyramids. @@ -38,6 +39,16 @@ public record LodStatistics( int pendingTransitions ) { } + /** Read-only logical L0 tile information used by surface inspection UI. */ + public record TileInspection( + int tileId, + int activeLod, + int startColumn, + int endColumnExclusive, + int startRow, + int endRowExclusive + ) { } + public enum ColorMode { IMAGE, HEIGHT @@ -295,6 +306,38 @@ public int getPendingTransitionCount() { return lodManager.getPendingTransitionCount(); } + /** + * Returns the logical L0 tile containing the supplied visual render cell. + * Tile geometry/LOD ownership stays inside this renderer; callers receive only + * immutable bounds and the currently active LOD. + */ + public OptionalNo animation, no ramp, no state: this returns one static image per call, + * intended for parts whose emissive color never changes at runtime (e.g. a + * static laser sight). For a part whose glow color or intensity changes over + * time, see {@code LaserEmitterEntity}'s heat-glow LUT instead - that class + * pre-builds a strip of these same 1x1 images and swaps between them. + */ +public final class SolidColorTexture { + + private SolidColorTexture() { + } + + /** Returns a fresh 1x1 image filled with {@code color}, alpha included. + * @param color + * @return */ + public static Image of(Color color) { + WritableImage img = new WritableImage(1, 1); + img.getPixelWriter().setColor(0, 0, color); + return img; + } +} \ No newline at end of file diff --git a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java index 636a3ed0..2750c8a1 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java @@ -8,6 +8,7 @@ import edu.jhuapl.trinity.javafx.events.TerrainEvent; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationTube; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.utils.JavaFX3DUtils; import edu.jhuapl.trinity.utils.ResourceUtils; import edu.jhuapl.trinity.utils.Utils; diff --git a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java index 3aa79a58..3f428a3f 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/VectorizeImageTest.java @@ -4,6 +4,7 @@ import edu.jhuapl.trinity.javafx.events.ImageEvent; import edu.jhuapl.trinity.javafx.events.TerrainEvent; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; +import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.HyperSurfacePlotMesh; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.animation.AnimationTimer; import javafx.application.Application;